import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { inlinePseudoElements } from '../src/modules/pseudo.js'
vi.mock('../src/utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
fetchImage: vi.fn(),
inlineSingleBackgroundEntry: vi.fn(),
}
})
vi.mock('../src/modules/fonts.js', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
iconToImage: vi.fn(),
}
})
vi.mock('../src/modules/counter.js', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
buildCounterContext: vi.fn(actual.buildCounterContext),
}
})
import * as helpers from '../src/utils/index.js'
import * as fonts from '../src/modules/fonts.js'
import { buildCounterContext } from '../src/modules/counter.js'
const sessionCache = {
styleMap: new Map(),
styleCache: new WeakMap()
}
describe('inlinePseudoElements', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not fail with simple elements', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
await expect(inlinePseudoElements(el, clone, sessionCache, {})).resolves.toBeUndefined()
})
it('handles ::before with text content', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? '"★"'
: prop === 'font-family' ? 'Arial'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'none'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles ::before with icon font', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? '"★"'
: prop === 'font-family' ? 'Font Awesome'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'none'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Font Awesome'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
fonts.iconToImage.mockResolvedValue('data:image/png;base64,icon')
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('passes the pseudo-element font style to icon rasterization', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
const styleTag = document.createElement('style')
styleTag.textContent = `.pseudo-font-style-target::before {
content: "\\e001";
font-family: "Font Awesome";
font-size: 32px;
font-weight: 700;
font-style: italic;
color: rgb(18, 52, 86);
}`
document.head.appendChild(styleTag)
el.className = 'pseudo-font-style-target'
document.body.appendChild(el)
fonts.iconToImage.mockResolvedValue({ dataUrl: 'data:image/png;base64,icon', width: 32, height: 32 })
const pseudoStyle = getComputedStyle(el, '::before')
try {
await inlinePseudoElements(el, clone, sessionCache, {})
expect(fonts.iconToImage).toHaveBeenCalledWith(
'\ue001', pseudoStyle.fontFamily, 700, 32, pseudoStyle.color, 'italic'
)
} finally {
el.remove()
styleTag.remove()
}
})
it('handles ::before with url content', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? 'url("https://test.com/img.png")'
: prop === 'font-family' ? 'Arial'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'none'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles ::before with background-image (data url)', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? 'none'
: prop === 'font-family' ? 'Arial'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'url("data:image/png;base64,abc")'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles ::before with background-image (fetch ok)', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? 'none'
: prop === 'font-family' ? 'Arial'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'url("https://test.com/img.png")'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles ::before with background-image (fetch error)', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) =>
prop === 'content' ? 'none'
: prop === 'font-family' ? 'Arial'
: prop === 'font-size' ? '32'
: prop === 'font-weight' ? '400'
: prop === 'color' ? '#000'
: prop === 'background-image' ? 'url("https://test.com/img.png")'
: prop === 'background-color' ? 'transparent'
: '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
})
helpers.fetchImage.mockRejectedValue(new Error('fail'))
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('cubre el catch de error en inlineSingleBackgroundEntry', async () => {
helpers.inlineSingleBackgroundEntry.mockRejectedValue(new Error('fail'))
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) => prop === 'background-image' ? 'url("data:image/png;base64,abc")' : '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('cubre el inlining exitoso con inlineSingleBackgroundEntry', async () => {
helpers.inlineSingleBackgroundEntry.mockResolvedValue('url("data:image/png;base64,abc")')
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) => prop === 'background-image' ? 'url("https://test.com/img.png")' : '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles ::before with no visible box', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return { getPropertyValue: () => 'none' }
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('does not add suppression markers to hosts without pseudo rules', async () => {
const style = document.createElement('style')
style.textContent = '.pseudo-sentinel::before { content: "x"; }'
document.head.appendChild(style)
const source = document.createElement('div')
const clone = source.cloneNode(true)
try {
await inlinePseudoElements(source, clone, { styleMap: new Map(), styleCache: new WeakMap() }, {})
expect(clone.hasAttribute('data-snapdom-has-before')).toBe(false)
expect(clone.hasAttribute('data-snapdom-has-after')).toBe(false)
} finally {
style.remove()
}
})
it('does not materialize and suppresses non-generated before and after pseudo-elements', async () => {
const cases = [
['content-none', '::before'],
['content-normal', '::before'],
['display-none', '::after'],
]
const host = document.createElement('div')
const style = document.createElement('style')
style.textContent = `
.content-none::before {
content: none;
display: block;
width: 20px;
height: 20px;
background-color: red;
}
.content-normal::before {
content: normal;
display: block;
width: 20px;
height: 20px;
background-color: red;
}
.display-none::after {
content: "visible text";
display: none;
}
`
document.head.appendChild(style)
document.body.appendChild(host)
try {
const materialized = {}
const suppressed = {}
for (const [className, pseudo] of cases) {
const source = document.createElement('div')
source.className = className
host.appendChild(source)
const clone = source.cloneNode(true)
const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
await inlinePseudoElements(source, clone, localSessionCache, {})
materialized[className] = Boolean(clone.querySelector(`[data-snapdom-pseudo="${pseudo}"]`))
const marker = pseudo === '::before'
? 'data-snapdom-has-before'
: 'data-snapdom-has-after'
suppressed[className] = clone.hasAttribute(marker)
}
expect(materialized).toEqual({
'content-none': false,
'content-normal': false,
'display-none': false,
})
expect(suppressed).toEqual({
'content-none': true,
'content-normal': true,
'display-none': true,
})
} finally {
host.remove()
style.remove()
}
})
it('handles ::first-letter with no textNode', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ getPropertyValue: () => '' }))
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('handles error in pseudo processing', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { throw new Error('fail') })
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('ignores if source no es Element', async () => {
const notElement = {}
const clone = document.createElement('div')
await expect(inlinePseudoElements(notElement, clone, sessionCache, {})).resolves.toBeUndefined()
})
it('ignores if clone no es Element', async () => {
const el = document.createElement('div')
const notElement = {}
await expect(inlinePseudoElements(el, notElement, {})).resolves.toBeUndefined()
})
it('inserta pseudoEl como ::after', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::after') return {
getPropertyValue: (prop) => prop === 'content' ? '"after"' : '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('inserta pseudoEl como ::before', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return {
getPropertyValue: (prop) => prop === 'content' ? '"before"' : '',
color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
}
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('maneja ::first-letter meaningful', async () => {
const el = document.createElement('div')
el.textContent = 'Test'
const clone = document.createElement('div')
clone.textContent = 'Test'
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::first-letter') return {
getPropertyValue: (prop) => prop === 'color' ? '#f00' : '', color: '#f00', fontSize: '32px'
}
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('inserta ambos pseudoEl ::before y ::after', async () => {
const el = document.createElement('div')
const clone = document.createElement('div')
vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
if (pseudo === '::before') return { getPropertyValue: () => '"before"' }
if (pseudo === '::after') return { getPropertyValue: () => '"after"' }
return { getPropertyValue: () => '' }
})
await inlinePseudoElements(el, clone, sessionCache, {})
window.getComputedStyle.mockRestore()
})
it('should inline ::first-letter when style is meaningful', async () => {
const el = document.createElement('p')
el.textContent = '¡Hola mundo!'
el.style.setProperty('color', 'black')
document.body.appendChild(el)
const clone = el.cloneNode(true)
const style = document.createElement('style')
style.textContent = `
p::first-letter {
color: red;
font-size: 200%;
}
`
document.head.appendChild(style)
await inlinePseudoElements(el, clone, sessionCache, {})
const firstLetterEl = clone.querySelector('[data-snapdom-pseudo="::first-letter"]')
expect(firstLetterEl).toBeTruthy()
expect(firstLetterEl.textContent.length).toBeGreaterThan(0)
})
it('#447: does not wrap the first letter of a textarea (would drop it from the value)', async () => {
const ta = document.createElement('textarea')
ta.value = 'ajdalfjllalkj'
document.body.appendChild(ta)
const style = document.createElement('style')
style.textContent = 'li::before { content: "x"; }'
document.head.appendChild(style)
const clone = ta.cloneNode(true)
clone.textContent = ta.value
await inlinePseudoElements(ta, clone, sessionCache, {})
expect(clone.querySelector('[data-snapdom-pseudo]')).toBeNull()
expect(clone.value).toBe('ajdalfjllalkj')
})
it('should inline background-image entries for pseudo-element', async () => {
const el = document.createElement('div')
document.body.appendChild(el)
const style = document.createElement('style')
style.textContent = `
div::after {
content: " ";
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E");
display: inline-block;
width: 10px;
height: 10px;
}
`
document.head.appendChild(style)
const clone = el.cloneNode(true)
await inlinePseudoElements(el, clone, sessionCache, {})
const pseudoAfter = clone.querySelector('[data-snapdom-pseudo="::after"]')
expect(pseudoAfter).toBeTruthy()
expect(pseudoAfter.style.backgroundImage.startsWith('url("data:image/')).toBeTruthy()
})
it('joins counter() with adjacent string token without source whitespace', async () => {
const ol = document.createElement('ol')
ol.className = 'reg-235-ol'
const li = document.createElement('li')
li.className = 'reg-235-li'
li.textContent = 'item'
ol.appendChild(li)
document.body.appendChild(ol)
const style = document.createElement('style')
style.textContent = `
.reg-235-ol { counter-reset: item; list-style: none; }
.reg-235-li::before {
counter-increment: item;
content: counter(item) ")";
}
`
document.head.appendChild(style)
const cloneOl = ol.cloneNode(true)
const cloneLi = cloneOl.firstElementChild
await inlinePseudoElements(li, cloneLi, sessionCache, {})
const before = cloneLi.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
expect(before.textContent).toBe('1)')
})
it('never walks the document for counter state when no pseudo uses counters', async () => {
const el = document.createElement('div')
el.className = 'no-counters-el'
document.body.appendChild(el)
const style = document.createElement('style')
style.textContent = '.no-counters-el::before { content: "plain text"; }'
document.head.appendChild(style)
const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
const clone = el.cloneNode(true)
await inlinePseudoElements(el, clone, localSessionCache, {})
expect(buildCounterContext).not.toHaveBeenCalled()
})
it('builds the document counter context lazily, once, only when a pseudo needs it', async () => {
const ol = document.createElement('ol')
ol.className = 'lazy-ctx-ol'
const li1 = document.createElement('li')
li1.className = 'lazy-ctx-li'
const li2 = document.createElement('li')
li2.className = 'lazy-ctx-li'
ol.append(li1, li2)
document.body.appendChild(ol)
const style = document.createElement('style')
style.textContent = `
.lazy-ctx-ol { counter-reset: item; list-style: none; }
.lazy-ctx-li::before { counter-increment: item; content: counter(item); }
`
document.head.appendChild(style)
const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
const cloneOl = ol.cloneNode(true)
await inlinePseudoElements(li1, cloneOl.children[0], localSessionCache, {})
await inlinePseudoElements(li2, cloneOl.children[1], localSessionCache, {})
expect(buildCounterContext).toHaveBeenCalledTimes(1)
})
it('applies counter-set on a pseudo element', async () => {
const ol = document.createElement('ol')
ol.className = 'cset-ol'
const li = document.createElement('li')
li.className = 'cset-li'
li.textContent = 'x'
ol.appendChild(li)
document.body.appendChild(ol)
const style = document.createElement('style')
style.textContent = `
.cset-ol { counter-reset: item; list-style: none; }
.cset-li::before {
counter-set: item 41;
content: counter(item);
}
`
document.head.appendChild(style)
const cloneOl = ol.cloneNode(true)
const cloneLi = cloneOl.firstElementChild
await inlinePseudoElements(li, cloneLi, sessionCache, {})
const before = cloneLi.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
expect(before.textContent).toBe('41')
ol.remove()
style.remove()
})
it('renders an empty-content pseudo that only has a border-bottom', async () => {
const el = document.createElement('div')
el.className = 'b419-empty'
el.textContent = 'host'
document.body.appendChild(el)
const style = document.createElement('style')
style.textContent = `
.b419-empty::before {
content: "";
position: absolute;
bottom: 0;
width: 100%;
border-bottom: 1px solid red;
}
`
document.head.appendChild(style)
const clone = el.cloneNode(true)
await inlinePseudoElements(el, clone, { styleMap: new Map(), styleCache: new WeakMap() }, {})
const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
el.remove()
style.remove()
})
it('keeps the border-bottom in the snapshot of a content+border pseudo', async () => {
const el = document.createElement('div')
el.className = 'b419-text'
el.textContent = 'host'
document.body.appendChild(el)
const style = document.createElement('style')
style.textContent = `
.b419-text::before {
content: "B";
border-bottom: 1px solid red;
}
`
document.head.appendChild(style)
const sessionCache2 = { styleMap: new Map(), styleCache: new WeakMap() }
const clone = el.cloneNode(true)
await inlinePseudoElements(el, clone, sessionCache2, {})
const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
const key = sessionCache2.styleMap.get(before) || ''
expect(key).toMatch(/border-bottom-width:\s*1px/)
expect(key).toMatch(/border-bottom-style:\s*solid/)
el.remove()
style.remove()
})
it('keeps an empty box-generating pseudo used as a layout spacer', async () => {
const wrap = document.createElement('div')
wrap.className = 'centerer'
wrap.textContent = 'modal'
document.body.appendChild(wrap)
const style = document.createElement('style')
style.textContent = `
.centerer { height: 200px; text-align: center; }
.centerer::before {
content: "";
display: inline-block;
width: 0;
height: 100%;
vertical-align: middle;
}
`
document.head.appendChild(style)
const sessionCache3 = { styleMap: new Map(), styleCache: new WeakMap() }
const clone = wrap.cloneNode(true)
await inlinePseudoElements(wrap, clone, sessionCache3, {})
const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
const key = sessionCache3.styleMap.get(before) || ''
expect(key).toMatch(/display:\s*inline-block/)
expect(key).toMatch(/vertical-align:\s*middle|alignment-baseline:\s*middle/)
wrap.remove()
style.remove()
})
it('renders content: image-set(...) as an image, not literal text', async () => {
const PX1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=='
const PX2 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII='
const el = document.createElement('div')
el.className = 'imageset-target'
document.body.appendChild(el)
const style = document.createElement('style')
style.textContent = `
.imageset-target::before {
content: image-set(url("${PX1}") 1x, url("${PX2}") 2x);
display: inline-block;
width: 20px;
height: 20px;
}
`
document.head.appendChild(style)
const sessionCache4 = { styleMap: new Map(), styleCache: new WeakMap() }
const clone = el.cloneNode(true)
await inlinePseudoElements(el, clone, sessionCache4, {})
const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
expect(before).toBeTruthy()
const img = before.querySelector('img')
expect(img).toBeTruthy()
expect(img.src).toMatch(/^data:image\/png/)
expect(before.textContent).toBe('')
el.remove()
style.remove()
})
})
describe('quote keywords in pseudo content (fidelity: literal "open-quote" bug)', () => {
afterEach(() => {
document.head.querySelectorAll('style[data-quote-test]').forEach((s) => s.remove())
document.body.innerHTML = ''
})
async function captureSvg(el) {
const { snapdom } = await import('../src/api/snapdom.js')
const res = await snapdom(el, { cache: 'disabled' })
return decodeURIComponent(res.url.split(',')[1]).replace(/<style[\s\S]*?<\/style>/g, '')
}
it('resolves open/close-quote from the computed quotes pairs', async () => {
const style = document.createElement('style')
style.setAttribute('data-quote-test', '')
style.textContent = `
.zz-q { quotes: "«" "»"; }
.zz-q::before { content: open-quote; }
.zz-q::after { content: close-quote; }
`
document.head.appendChild(style)
const el = document.createElement('div')
el.className = 'zz-q'
el.textContent = 'cita'
document.body.appendChild(el)
const svg = await captureSvg(el)
expect(svg).toContain('«')
expect(svg).toContain('»')
expect(svg).not.toContain('open-quote')
})
it('falls back to typographic quotes when quotes computes auto, and renders nothing for no-*-quote', async () => {
const style = document.createElement('style')
style.setAttribute('data-quote-test', '')
style.textContent = `
.zz-q2::before { content: open-quote; }
.zz-q2::after { content: no-close-quote; }
`
document.head.appendChild(style)
const el = document.createElement('div')
el.className = 'zz-q2'
el.textContent = 'hola'
document.body.appendChild(el)
const svg = await captureSvg(el)
expect(svg).toContain('“')
expect(svg).not.toContain('open-quote')
expect(svg).not.toContain('no-close-quote')
})
})
describe('scoped ::marker and ::first-line rules', () => {
afterEach(() => {
document.head.querySelectorAll('style[data-mk-test]').forEach((s) => s.remove())
document.body.innerHTML = ''
})
async function captureSvg(el) {
const { snapdom } = await import('../src/api/snapdom.js')
const res = await snapdom(el, { cache: 'disabled' })
return decodeURIComponent(res.url.split(',')[1])
}
it('authored ::marker color/content survives as a scoped rule', async () => {
const style = document.createElement('style')
style.setAttribute('data-mk-test', '')
style.textContent = '.zz-list li::marker { color: rgb(200, 10, 10); content: "→ "; }'
document.head.appendChild(style)
const ul = document.createElement('ul')
ul.className = 'zz-list'
ul.innerHTML = '<li>uno</li><li>dos</li>'
document.body.appendChild(ul)
const svg = await captureSvg(ul)
expect(svg).toContain('::marker')
expect(svg).toContain('rgb(200, 10, 10)')
expect(svg).toContain('data-sd-p')
})
it('authored ::first-line styling survives as a scoped rule', async () => {
const style = document.createElement('style')
style.setAttribute('data-mk-test', '')
style.textContent = '.zz-lede::first-line { font-weight: 700; color: rgb(10, 10, 200); }'
document.head.appendChild(style)
const p = document.createElement('p')
p.className = 'zz-lede'
p.style.width = '120px'
p.textContent = 'primera línea de un párrafo largo que envuelve en varias líneas seguro'
document.body.appendChild(p)
const svg = await captureSvg(p)
expect(svg).toContain('::first-line')
expect(svg).toContain('rgb(10, 10, 200)')
})
it('default markers emit no scoped rule (zero output cost)', async () => {
const ul = document.createElement('ul')
ul.innerHTML = '<li>plain</li>'
document.body.appendChild(ul)
const svg = await captureSvg(ul)
expect(svg).not.toContain('::marker')
})
it('CSS-nested pseudo rules still paint', async () => {
async function pinkPixels(host) {
const { snapdom } = await import('../src/api/snapdom.js')
const res = await snapdom(host, { cache: 'disabled', dpr: 1, scale: 1 })
const c = await res.toCanvas()
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data
let n = 0
for (let i = 0; i < d.length; i += 4) {
if (d[i] > 180 && d[i + 1] < 80 && d[i + 2] < 110 && d[i + 3] > 40) n++
}
return n
}
const shapes = {
'flat (control)': '.zzp .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px }',
'nested descendant': '.zzp { color:#111; & .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px } }',
'nested inside @media': '@media (min-width:1px){ .zzp { & .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px } } }',
}
for (const [label, css] of Object.entries(shapes)) {
const style = document.createElement('style')
style.setAttribute('data-mk-test', '')
style.textContent = css
document.head.appendChild(style)
const host = document.createElement('div')
host.innerHTML = '<div class="zzp">plan <span class="feat">feature</span></div>'
document.body.appendChild(host)
const painted = await pinkPixels(host)
style.remove()
host.remove()
expect(painted, label).toBeGreaterThan(20)
}
})
it('preflight follows @import (component CSS often lives one sheet down)', async () => {
const { shouldProcessPseudos } = await import('../src/modules/pseudo.js')
const frame = document.createElement('iframe')
const blob = new Blob(['.feat::before{content:"✔";color:red}'], { type: 'text/css' })
const url = URL.createObjectURL(blob)
try {
await new Promise((resolve) => {
frame.onload = resolve
frame.srcdoc = `<!doctype html><html><head><style>@import url("${url}"); .x{color:#111}</style></head><body></body></html>`
document.body.appendChild(frame)
})
const doc = frame.contentDocument
expect(doc.styleSheets[0].cssRules[0].styleSheet.cssRules.length).toBeGreaterThan(0)
expect(shouldProcessPseudos(doc)).toBe(true)
} finally {
URL.revokeObjectURL(url)
frame.remove()
}
})
it('preflight keeps the pseudo pass for marker/first-line-only pages', async () => {
const { shouldProcessPseudos } = await import('../src/modules/pseudo.js')
const frame = document.createElement('iframe')
document.body.appendChild(frame)
const doc = frame.contentDocument
try {
doc.head.innerHTML = '<style>li::marker { color: rgb(200, 10, 10); }</style>'
expect(shouldProcessPseudos(doc)).toBe(true)
doc.head.innerHTML = '<style>.lede::first-line { font-weight: 700; }</style>'
expect(shouldProcessPseudos(doc)).toBe(true)
doc.head.innerHTML = '<style>.plain { color: red; }</style>'
expect(shouldProcessPseudos(doc)).toBe(false)
} finally {
frame.remove()
}
})
})