import { describe, it, expect, vi, beforeEach } from 'vitest'
async function loadInlineAllStylesFresh() {
await vi.resetModules()
const mod = await import('../src/modules/styles.js')
return mod.inlineAllStyles
}
class MOStub {
static count = 0
constructor() {
MOStub.count++
}
observe() {}
disconnect() {}
takeRecords() { return [] }
}
function freshSession() {
return {
styleMap: new Map(),
styleCache: new WeakMap(),
nodeMap: new Map(),
}
}
describe('inlineAllStyles – branches y firmas', () => {
beforeEach(() => {
MOStub.count = 0
globalThis.MutationObserver = MOStub
})
it('early-return para <style> (no escribe en styleMap)', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('style')
src.textContent = '.x{color:red}'
const clone = document.createElement('style')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'auto' })
expect(session.styleMap.size).toBe(0)
expect(MOStub.count).toBe(0)
})
it('wires invalidation once when cache !== "disabled"', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const s1 = document.createElement('div')
const c1 = document.createElement('div')
const s2 = document.createElement('span')
const c2 = document.createElement('span')
const session = freshSession()
await inlineAllStyles(s1, c1, session, { cache: 'auto' })
await inlineAllStyles(s2, c2, session, { cache: 'soft' })
expect(MOStub.count).toBe(2)
expect(session.styleMap.has(c1)).toBe(true)
expect(session.styleMap.has(c2)).toBe(true)
})
it('does NOT wire invalidation when cache === "disabled"', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
const clone = document.createElement('div')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'disabled' })
expect(MOStub.count).toBe(0)
expect(session.styleMap.has(clone)).toBe(true)
})
it('firma #1: (source, clone, sessionCache, options)', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('p')
const clone = document.createElement('p')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'auto' })
expect(session.styleMap.has(clone)).toBe(true)
})
it('firma #2: (source, clone, ctx) pasando ctx completo', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const { cache } = await import('../src/core/cache.js')
const src = document.createElement('em')
const clone = document.createElement('em')
const session = freshSession()
const ctx = {
session,
persist: {
snapshotKeyCache: new Map(),
defaultStyle: cache.defaultStyle,
baseStyle: cache.baseStyle,
image: cache.image,
resource: cache.resource,
background: cache.background,
},
options: { cache: 'auto' },
}
await inlineAllStyles(src, clone, ctx)
expect(session.styleMap.has(clone)).toBe(true)
})
it('signature #3: (source, clone, options) using options only', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('strong')
const clone = document.createElement('strong')
expect(inlineAllStyles(src, clone, { cache: 'soft' })).toBeUndefined()
})
it('#348: excludeStyleProps regex excludes matching props from snapshot', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
src.style.color = 'red'
src.style.fontSize = '13.37px'
document.body.appendChild(src)
const clone = document.createElement('div')
const session = freshSession()
await inlineAllStyles(src, clone, session, {
cache: 'auto',
excludeStyleProps: /^color$/
})
document.body.removeChild(src)
const key = session.styleMap.get(clone)
expect(key).toBeDefined()
expect(key).not.toMatch(/(?:^|;)color:/)
expect(key).toMatch(/font-size:/)
})
it('#348: excludeStyleProps function excludes matching props', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('span')
src.style.fontSize = '14px'
const clone = document.createElement('span')
const session = freshSession()
await inlineAllStyles(src, clone, session, {
cache: 'auto',
excludeStyleProps: (prop) => prop === 'font-size'
})
const key = session.styleMap.get(clone)
expect(key).toBeDefined()
expect(key).not.toMatch(/font-size:/)
})
it('#348: snapshot cache invalidates when excludeStyleProps changes between captures', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
src.style.color = 'red'
src.style.fontSize = '13.37px'
document.body.appendChild(src)
const clone1 = document.createElement('div')
const session1 = freshSession()
await inlineAllStyles(src, clone1, session1, { cache: 'auto', excludeStyleProps: /^color$/ })
expect(session1.styleMap.get(clone1)).not.toMatch(/(?:^|;)color:/)
const clone2 = document.createElement('div')
const session2 = freshSession()
await inlineAllStyles(src, clone2, session2, { cache: 'auto' })
const key2 = session2.styleMap.get(clone2)
document.body.removeChild(src)
expect(key2).toMatch(/(?:^|;)color:/)
})
it('#362: border: 0 solid normalizes to border: none in snapshot', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const style = document.createElement('style')
style.textContent = '* { border: 0 solid; }'
document.head.appendChild(style)
const src = document.createElement('div')
document.body.appendChild(src)
const clone = document.createElement('div')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'auto' })
document.body.removeChild(src)
document.head.removeChild(style)
const key = session.styleMap.get(clone)
expect(key).toBeDefined()
expect(key).toMatch(/\bborder:\s*none\b/)
})
it('content-visibility:hidden is carried verbatim into the snapshot (NEW-10)', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
src.style.setProperty('content-visibility', 'hidden')
document.body.appendChild(src)
const clone = document.createElement('div')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'auto' })
document.body.removeChild(src)
const key = session.styleMap.get(clone)
expect(key).toMatch(/\bcontent-visibility:\s*hidden\b/)
expect(key).not.toMatch(/(?<!-)\bvisibility:\s*hidden\b/)
})
it('cachea getComputedStyle en session.styleCache (una sola lectura por source)', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
const clone1 = document.createElement('div')
const clone2 = document.createElement('div')
const session = freshSession()
const spy = vi.spyOn(window, 'getComputedStyle')
await inlineAllStyles(src, clone1, session, { cache: 'auto' })
await inlineAllStyles(src, clone2, session, { cache: 'auto' })
expect(spy).toHaveBeenCalledTimes(1)
expect(session.styleMap.has(clone1)).toBe(true)
expect(session.styleMap.has(clone2)).toBe(true)
spy.mockRestore()
})
it('ROB-1: inlineAllStyles does not throw for a detached (not-connected) element', async () => {
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
src.style.color = 'red'
const clone = document.createElement('div')
const session = freshSession()
expect(() => inlineAllStyles(src, clone, session, { cache: 'auto' })).not.toThrow()
expect(session.styleMap.has(clone)).toBe(true)
})
it('PERF-4: snapshotKeyCache does not grow unboundedly — evicts when oversized', async () => {
const { notifyStyleEpoch } = await import('../src/modules/styles.js')
for (let i = 0; i < 5; i++) notifyStyleEpoch()
const inlineAllStyles = await loadInlineAllStylesFresh()
const src = document.createElement('div')
src.style.color = 'blue'
const clone = document.createElement('div')
const session = freshSession()
await inlineAllStyles(src, clone, session, { cache: 'auto' })
expect(session.styleMap.has(clone)).toBe(true)
})
it('interaction state invalidates cached style snapshots', async () => {
const { snapdom } = await import('../src/api/snapdom.js')
const style = document.createElement('style')
style.textContent = '.zzf{background:rgb(255,255,255);width:150px;height:30px;border:1px solid #999}' +
'.zzf:focus{background:rgb(0,0,255)}'
document.head.appendChild(style)
const host = document.createElement('div')
host.innerHTML = '<input class="zzf">'
document.body.appendChild(host)
const input = host.querySelector('.zzf')
const blue = async () => {
const res = await snapdom(host, { 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] < 80 && d[i + 1] < 80 && d[i + 2] > 180 && d[i + 3] > 40) n++
}
return n
}
await blue()
await blue()
input.focus()
await new Promise((r) => setTimeout(r, 0))
expect(input.matches(':focus')).toBe(true)
try {
expect(await blue()).toBeGreaterThan(20)
} finally {
style.remove()
host.remove()
}
})
})