snapshot.ts 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. /**
  2. * DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
  3. * files structural. Two normalizations, both on a clone (the live DOM is
  4. * untouched, so class/tag queries keep working):
  5. *
  6. * - CSS-module scoped class names (`_frame_334d2d`, this repo's
  7. * `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
  8. * CSS edits do not churn snapshots.
  9. * - `<svg>` internals collapse to a `data-content` fingerprint on the svg
  10. * element: path geometry is print noise, but the fingerprint still flips
  11. * when an icon's artwork actually changes.
  12. */
  13. import { expect } from 'vitest'
  14. import type { SnapshotSerializer } from 'vitest'
  15. /** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
  16. const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
  17. /** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
  18. function normalizeClassValue(value: string): string {
  19. return value
  20. .split(/\s+/)
  21. .filter(token => token !== '')
  22. .map(token => token.replace(SCOPED_CLASS, '$1'))
  23. .join(' ')
  24. }
  25. /** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
  26. function fingerprint(markup: string): string {
  27. let hash = 0x811c9dc5
  28. for (let i = 0; i < markup.length; i++) {
  29. hash ^= markup.charCodeAt(i)
  30. hash = Math.imul(hash, 0x01000193)
  31. }
  32. return (hash >>> 0).toString(16).padStart(8, '0')
  33. }
  34. /** svg elements of a subtree, the root included when it is one. */
  35. function svgsOf(root: Element): Element[] {
  36. const svgs: Element[] = [...root.querySelectorAll('svg')]
  37. if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
  38. return svgs
  39. }
  40. /** Whether serializing this subtree needs a normalized clone. */
  41. function needsNormalization(root: Element): boolean {
  42. const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
  43. const value = el.getAttribute('class')
  44. return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
  45. })
  46. return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
  47. }
  48. /**
  49. * The serializer plugin. Matches DOM elements whose subtree carries a scoped
  50. * class or svg internals; serializes a normalized clone, which no longer
  51. * matches, so printing falls through to the built-in DOM element serializer.
  52. */
  53. export const domSnapshotSerializer: SnapshotSerializer = {
  54. test(value: unknown): boolean {
  55. return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
  56. },
  57. serialize(value, config, indentation, depth, refs, printer): string {
  58. const clone = (value as Element).cloneNode(true) as Element
  59. for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
  60. const raw = el.getAttribute('class')
  61. if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
  62. }
  63. for (const svg of svgsOf(clone)) {
  64. if (svg.childNodes.length === 0) continue
  65. svg.setAttribute('data-content', fingerprint(svg.innerHTML))
  66. svg.replaceChildren()
  67. }
  68. return printer(clone, config, indentation, depth, refs)
  69. },
  70. }
  71. let registered = false
  72. /**
  73. * Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
  74. * SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
  75. * runtime import and call it themselves.
  76. */
  77. export function registerDomSnapshotSerializer(): void {
  78. if (registered) return
  79. registered = true
  80. expect.addSnapshotSerializer(domSnapshotSerializer)
  81. }