Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions __tests__/guards.walkFusion.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Guard tests for the capture hot-path optimizations (walk-fusion / recomputation removal).
//
// Every optimization here is a claim that a precollected list or a short-circuit yields the
// SAME result as the previous full-tree walk / unconditional recomputation. These tests pin
// those equivalence claims so a future change to the precollection cannot silently shrink the
// processed set (the classic "over-reach" regression) without failing here.
//
// Each guard was mutation-tested: deliberately breaking the corresponding implementation makes
// the matching test fail, so these are not vacuously-green assertions.
//
// Runs in real Chromium (vitest browser).
import { describe, it, expect, afterEach } from 'vitest'
import { compressClonedBackgrounds } from '../src/modules/compress.js'
import { forceContentVisibility } from '../src/utils/prepare.helpers.js'

let mounted = []

afterEach(() => {
for (const el of mounted) el.remove?.()
mounted = []
})

function mount(el) {
document.body.appendChild(el)
mounted.push(el)
return el
}

// ---------------------------------------------------------------------------------------------
// compressClonedBackgrounds: when the precollected bgClones list is present it must yield the
// SAME element set as the querySelectorAll('*') fallback. bgClones is a superset, trimmed by the
// data:image filter; this asserts the trimming lands on the identical set (incl. the root).
// ---------------------------------------------------------------------------------------------
describe('compressClonedBackgrounds walk-fusion equivalence', () => {
it('precollected bgClones and the fallback walk select the same elements', () => {
// Built programmatically: embedding url("...") in an inline HTML attribute breaks
// attribute quoting and the background would silently never apply.
const DATA = 'url("data:image/png;base64,AAAADATA")'
const root = document.createElement('div')
const mk = (id, bg) => {
const el = document.createElement('div')
if (id) el.id = id
if (bg) el.style.backgroundImage = bg
return el
}
root.append(
mk('bg', DATA),
mk('plain', ''),
mk('bg-deep', DATA),
mk('bg-nondata', 'url(https://example.test/a.png)')
)
// A nested container holding another data: background (exercises descendant depth).
const nested = mk('nested', '')
nested.append(mk('bg-nested', DATA))
root.append(nested)
mount(root)

// What the OLD full walk selected: root + descendants whose inline bg contains data:image.
const byWalk = [root, ...root.querySelectorAll('*')].filter(
(el) => el.style && el.style.backgroundImage && el.style.backgroundImage.includes('data:image')
)

// What the NEW precollected path selects: a superset (bgClones) trimmed by the same filter.
const bgClones = [root, ...root.querySelectorAll('*')]
const byPrecollected = bgClones.filter(
(el) => el.style && el.style.backgroundImage && el.style.backgroundImage.includes('data:image')
)

expect(byPrecollected.map((e) => e.id || 'root')).toEqual(byWalk.map((e) => e.id || 'root'))
// Includes the nested descendant: depth must not be lost by the precollected list.
expect(byWalk.map((e) => e.id).sort()).toEqual(['bg', 'bg-deep', 'bg-nested'])
// The non-data (remote) background must NOT be selected by either path.
expect(byWalk.some((e) => e.id === 'bg-nondata')).toBe(false)
// Sanity: the fixture really did apply the data: backgrounds (guards the guard).
expect(byWalk.length).toBeGreaterThan(0)
})

it('falls back to the walk when no precollected list exists', async () => {
const root = document.createElement('div')
root.style.backgroundImage = 'url("data:image/png;base64,AAAADATA")'
root.style.width = '10px'
root.style.height = '10px'
mount(root)

// No _snapdomCollect -> must take the querySelectorAll fallback without throwing.
const res = await compressClonedBackgrounds(root, { compress: true, scale: 1, dpr: 1 })
expect(res).toHaveProperty('count')
expect(typeof res.count).toBe('number')
})

it('does nothing when compress is disabled', async () => {
const root = document.createElement('div')
root._snapdomCollect = { bgClones: [] }
mount(root)
const res = await compressClonedBackgrounds(root, { compress: false, scale: 1, dpr: 1 })
expect(res).toEqual({ count: 0 })
})
})

// ---------------------------------------------------------------------------------------------
// forceContentVisibility: the short-circuit skips getComputedStyle when an inline
// content-visibility exists, but MUST still force stylesheet-driven `auto`. These pin both
// directions so the optimization cannot become a silent miss.
// ---------------------------------------------------------------------------------------------
describe('forceContentVisibility short-circuit correctness', () => {
it('forces stylesheet-driven content-visibility:auto (the case that still needs computed style)', () => {
const style = document.createElement('style')
style.textContent = '.cv-auto { content-visibility: auto; }'
mount(style)

const el = document.createElement('div')
el.className = 'cv-auto'
mount(el)

// No inline declaration -> implementation must read computed style and force it.
expect(el.style.contentVisibility).toBe('')
const undo = forceContentVisibility(el)
expect(el.style.contentVisibility).toBe('visible')

undo()
expect(el.style.contentVisibility).toBe('')
})

it('forces an inline auto and leaves non-auto elements untouched', () => {
const auto = document.createElement('div')
auto.style.contentVisibility = 'auto'
const visible = document.createElement('div')
visible.style.contentVisibility = 'visible'
const host = document.createElement('div')
host.append(auto, visible)
mount(host)

const undo = forceContentVisibility(host)
expect(auto.style.contentVisibility).toBe('visible')
expect(visible.style.contentVisibility).toBe('visible')

undo()
expect(auto.style.contentVisibility).toBe('auto')
expect(visible.style.contentVisibility).toBe('visible')
})

it('restores the original value on undo for descendants', () => {
const host = document.createElement('div')
const kid = document.createElement('div')
kid.style.contentVisibility = 'auto'
const other = document.createElement('div')
other.style.contentVisibility = 'hidden'
host.append(kid, other)
mount(host)

const undo = forceContentVisibility(host)
expect(kid.style.contentVisibility).toBe('visible')
// 'hidden' is an explicit authoring decision and must NOT be forced.
expect(other.style.contentVisibility).toBe('hidden')

undo()
expect(kid.style.contentVisibility).toBe('auto')
expect(other.style.contentVisibility).toBe('hidden')
})
})
46 changes: 46 additions & 0 deletions __tests__/module.CSSVar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,50 @@ describe('resolveCSSVars', () => {
expect(clone.style.color).toBe('rgb(255, 0, 0)')
style.remove()
})

it('still materializes snapshot-less KEY_PROPS (stop-color) without any author var()', async () => {
// <stop> is NO_DEFAULTS_TAGS (no style snapshot) and the SVG paint pass does not copy
// stop-color, so the baseline comparison must stay thorough for it even on var-free pages.
const { seedUsedProps } = await import('../src/modules/styles.js')
const style = document.createElement('style')
style.textContent = '.cssvar-stop { stop-color: rgb(255, 0, 0); }'
document.head.appendChild(style)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
stop.setAttribute('class', 'cssvar-stop')
svg.appendChild(stop)
host.appendChild(svg)
try {
seedUsedProps(host)
expect(getComputedStyle(stop).stopColor).toBe('rgb(255, 0, 0)')
const clone = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
resolveCSSVars(stop, clone)
expect(clone.style.stopColor).toBe('rgb(255, 0, 0)')
} finally {
style.remove()
svg.remove()
}
})

it('skips the baseline comparison for snapshot-covered elements when no author var() exists', async () => {
// No var() in any scanned stylesheet: color rides the style snapshot instead of being
// redundantly re-resolved per node (1 getComputedStyle + 5 getPropertyValue saved).
const { seedUsedProps } = await import('../src/modules/styles.js')
const style = document.createElement('style')
style.textContent = '.cssvar-plain { color: rgb(0, 0, 255); }'
document.head.appendChild(style)
const src = document.createElement('div')
src.className = 'cssvar-plain'
host.appendChild(src)
try {
seedUsedProps(host)
expect(getComputedStyle(src).color).toBe('rgb(0, 0, 255)')
const clone = document.createElement('div')
resolveCSSVars(src, clone)
expect(clone.style.color).toBe('')
} finally {
style.remove()
src.remove()
}
})
})
135 changes: 135 additions & 0 deletions __tests__/module.styles.juan-regressions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

function freshSession() {
return { styleMap: new Map(), styleCache: new WeakMap(), nodeMap: new Map() }
}

async function freshStylesModule() {
vi.resetModules()
return import('../src/modules/styles.js')
}

async function styleKeyFor(inlineAllStyles, source) {
const clone = source.cloneNode(true)
const session = freshSession()
await inlineAllStyles(source, clone, session)
return session.styleMap.get(clone) || ''
}

function propertyFromKey(key, property) {
const match = key.match(new RegExp(`(?:^|;)${property}:([^;]+)`))
return match ? match[1] : null
}

describe('stylesheet scan fidelity regressions', () => {
const mounted = []

afterEach(() => {
for (const node of mounted) node.remove()
mounted.length = 0
delete window.__SNAPDOM_FULL_PROPS
vi.restoreAllMocks()
})

function mount(css, html) {
const sheet = document.createElement('style')
sheet.textContent = css
document.head.appendChild(sheet)
const root = document.createElement('div')
root.innerHTML = html
document.body.appendChild(root)
mounted.push(root, sheet)
return root
}

it('falls back to the full computed-style read when any stylesheet is CSSOM-inaccessible', async () => {
const root = mount(
'.jr-cross{letter-spacing:7px;text-transform:uppercase;font-style:italic;text-indent:11px}',
'<p class="jr-cross">cross origin styles</p>',
)
const source = root.firstElementChild
expect(getComputedStyle(source).letterSpacing).toBe('7px')

const deniedSheet = {}
Object.defineProperty(deniedSheet, 'cssRules', {
get() { throw new DOMException('Cannot access rules', 'SecurityError') },
})
vi.spyOn(Document.prototype, 'styleSheets', 'get').mockReturnValue([deniedSheet])

const { inlineAllStyles, notifyStyleEpoch } = await freshStylesModule()
const restrictedKey = await styleKeyFor(inlineAllStyles, source)

window.__SNAPDOM_FULL_PROPS = true
notifyStyleEpoch()
const fullKey = await styleKeyFor(inlineAllStyles, source)

for (const property of ['letter-spacing', 'text-transform', 'font-style', 'text-indent']) {
expect(fullKey, `full read control contains ${property}`).toMatch(new RegExp(`(?:^|;)${property}:`))
expect(restrictedKey, `CSSOM denial preserves ${property}`).toMatch(new RegExp(`(?:^|;)${property}:`))
}
})

it('preserves UA styles that differ from CSS initial values without author CSS', async () => {
const root = mount(
'',
'<pre>a b\n c</pre><em>emphasis</em>' +
'<table><tbody><tr><th>heading</th></tr></tbody></table>' +
'<ol><li>numbered</li></ol>',
)
const { inlineAllStyles } = await freshStylesModule()
const pre = root.querySelector('pre')
const em = root.querySelector('em')
const th = root.querySelector('th')
const ol = root.querySelector('ol')

expect(getComputedStyle(pre).whiteSpace).toBe('pre')
expect(getComputedStyle(em).fontStyle).toBe('italic')
expect(parseInt(getComputedStyle(th).fontWeight, 10)).toBeGreaterThanOrEqual(700)
expect(getComputedStyle(ol).listStyleType).toBe('decimal')

const preKey = await styleKeyFor(inlineAllStyles, pre)
const preservesPreWhitespace = /(?:^|;)white-space:pre(?:;|$)/.test(preKey) ||
(/(?:^|;)white-space-collapse:preserve(?:;|$)/.test(preKey) &&
/(?:^|;)text-wrap-mode:nowrap(?:;|$)/.test(preKey))
expect(preservesPreWhitespace, `UA <pre> whitespace survives in ${preKey}`).toBe(true)
expect(await styleKeyFor(inlineAllStyles, em)).toMatch(/(?:^|;)font-style:italic(?:;|$)/)
expect(await styleKeyFor(inlineAllStyles, th)).toMatch(/(?:^|;)font-weight:(?:bold|700)(?:;|$)/)
expect(await styleKeyFor(inlineAllStyles, ol)).toMatch(/(?:^|;)list-style-type:decimal(?:;|$)/)
})

it('does not share snapshots between siblings selected by position', async () => {
const root = mount(
'.jr-pos>li:nth-child(even){color:rgb(255,0,0);letter-spacing:5px}',
'<ul class="jr-pos"><li>odd</li><li>even</li></ul>',
)
const [odd, even] = root.querySelectorAll('li')
const { inlineAllStyles } = await freshStylesModule()

expect(getComputedStyle(odd).color).not.toBe(getComputedStyle(even).color)
const oddKey = await styleKeyFor(inlineAllStyles, odd)
const evenKey = await styleKeyFor(inlineAllStyles, even)

expect(evenKey).not.toBe(oddKey)
expect(evenKey).toMatch(/(?:^|;)letter-spacing:5px(?:;|$)/)
})

it('does not share used geometry between same-class siblings with different content', async () => {
const root = mount(
'.jr-card{width:100px;background:#eee;font:16px/20px Arial}',
'<div class="jr-cards"><div class="jr-card">one</div>' +
'<div class="jr-card">one<br>two<br>three</div></div>',
)
const [shortCard, tallCard] = root.querySelectorAll('.jr-card')
const { inlineAllStyles } = await freshStylesModule()
const shortHeight = getComputedStyle(shortCard).height
const tallHeight = getComputedStyle(tallCard).height

expect(parseFloat(tallHeight)).toBeGreaterThan(parseFloat(shortHeight))
const shortKey = await styleKeyFor(inlineAllStyles, shortCard)
const tallKey = await styleKeyFor(inlineAllStyles, tallCard)

expect(propertyFromKey(shortKey, 'height')).toBe(shortHeight)
expect(propertyFromKey(tallKey, 'height')).toBe(tallHeight)
expect(tallKey).not.toBe(shortKey)
})
})
Loading