|
| 1 | +// Dual FNV-1a 32-bit hash — two independent passes with different seeds, |
| 2 | +// concatenated into a 16-character hex string for ~64-bit collision resistance. |
| 3 | +function fnvHash(str) { |
| 4 | + let h1 = 0x811c9dc5; // FNV offset basis (seed 1) |
| 5 | + let h2 = 0x050c5d1f; // Different offset basis (seed 2) |
| 6 | + const FNV_PRIME = 0x01000193; |
| 7 | + |
| 8 | + for (let i = 0; i < str.length; i++) { |
| 9 | + const c = str.charCodeAt(i); |
| 10 | + // eslint-disable-next-line no-bitwise |
| 11 | + h1 = ((h1 ^ c) * FNV_PRIME) | 0; |
| 12 | + // eslint-disable-next-line no-bitwise |
| 13 | + h2 = ((h2 ^ c) * FNV_PRIME) | 0; |
| 14 | + } |
| 15 | + // eslint-disable-next-line no-bitwise |
| 16 | + const part1 = (h1 >>> 0).toString(16).padStart(8, '0'); |
| 17 | + // eslint-disable-next-line no-bitwise |
| 18 | + const part2 = (h2 >>> 0).toString(16).padStart(8, '0'); |
| 19 | + return part1 + part2; |
| 20 | +} |
| 21 | + |
| 22 | +// --- Positional style properties to exclude from fingerprint --- |
| 23 | +const POSITIONAL_PROPERTIES = new Set([ |
| 24 | + 'top', |
| 25 | + 'left', |
| 26 | + 'right', |
| 27 | + 'bottom', |
| 28 | + 'position', |
| 29 | + 'transform', |
| 30 | + 'translate', |
| 31 | + 'rotate', |
| 32 | + 'scale', |
| 33 | + 'margin', |
| 34 | + 'margin-top', |
| 35 | + 'margin-right', |
| 36 | + 'margin-bottom', |
| 37 | + 'margin-left', |
| 38 | + 'padding', |
| 39 | + 'padding-top', |
| 40 | + 'padding-right', |
| 41 | + 'padding-bottom', |
| 42 | + 'padding-left', |
| 43 | + 'inset', |
| 44 | + 'inset-block', |
| 45 | + 'inset-inline', |
| 46 | + 'z-index', |
| 47 | + 'x', |
| 48 | + 'y' |
| 49 | +]); |
| 50 | + |
| 51 | +// --- Void elements (self-closing, no children) --- |
| 52 | +const VOID_ELEMENTS = new Set([ |
| 53 | + 'area', |
| 54 | + 'base', |
| 55 | + 'br', |
| 56 | + 'col', |
| 57 | + 'embed', |
| 58 | + 'hr', |
| 59 | + 'img', |
| 60 | + 'input', |
| 61 | + 'link', |
| 62 | + 'meta', |
| 63 | + 'param', |
| 64 | + 'source', |
| 65 | + 'track', |
| 66 | + 'wbr' |
| 67 | +]); |
| 68 | + |
| 69 | +// --- CSS-in-JS dynamic class regex --- |
| 70 | +// Matches: css-1a2b3c4d, sc-bdVTJa1, emotion-s1h4d7 |
| 71 | +// Does not match: css-reset, btn-primary |
| 72 | +const CSS_IN_JS_REGEX = /^[a-zA-Z][\w-]*-(?=[a-zA-Z0-9]*\d)[a-zA-Z0-9]{6,}$/; |
| 73 | + |
| 74 | +// (React synthetic ID and hash-based ID regexes removed — not needed |
| 75 | +// since ID is not in the 13-field fingerprint spec) |
| 76 | + |
| 77 | +// --- HTML entity decoding --- |
| 78 | +function decodeEntities(str) { |
| 79 | + return str |
| 80 | + .replace(/&/g, '&') |
| 81 | + .replace(/</g, '<') |
| 82 | + .replace(/>/g, '>') |
| 83 | + .replace(/"/g, '"') |
| 84 | + .replace(/'/g, "'") |
| 85 | + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => |
| 86 | + String.fromCharCode(parseInt(hex, 16)) |
| 87 | + ) |
| 88 | + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); |
| 89 | +} |
| 90 | + |
| 91 | +// --- Extract opening tag from outerHTML --- |
| 92 | +function extractOpeningTag(outerHTML) { |
| 93 | + const match = outerHTML.match(/^<[^>]+>/); |
| 94 | + return match ? match[0] : ''; |
| 95 | +} |
| 96 | + |
| 97 | +// --- Extract tag name --- |
| 98 | +function extractTagName(outerHTML) { |
| 99 | + const match = outerHTML.match(/^<(\w+)/); |
| 100 | + return match ? match[1].toLowerCase() : ''; |
| 101 | +} |
| 102 | + |
| 103 | +// --- Extract a single attribute value from the opening tag --- |
| 104 | +function extractAttribute(openingTag, attrName) { |
| 105 | + const regex = new RegExp(`\\s${attrName}="([^"]*)"`, 'i'); |
| 106 | + const match = openingTag.match(regex); |
| 107 | + return match ? match[1] : ''; |
| 108 | +} |
| 109 | + |
| 110 | +// --- Extract all aria-* attributes, sorted --- |
| 111 | +function extractAriaAttributes(openingTag) { |
| 112 | + const matches = []; |
| 113 | + const regex = /\s(aria-[\w-]+)="([^"]*)"/gi; |
| 114 | + let m = regex.exec(openingTag); |
| 115 | + while (m !== null) { |
| 116 | + matches.push([m[1].toLowerCase(), m[2]]); |
| 117 | + m = regex.exec(openingTag); |
| 118 | + } |
| 119 | + matches.sort((a, b) => a[0].localeCompare(b[0])); |
| 120 | + return matches.map(([key, val]) => `${key}=${val}`).join(','); |
| 121 | +} |
| 122 | + |
| 123 | +// --- Extract and filter class list --- |
| 124 | +function extractFilteredClassList(openingTag) { |
| 125 | + const classAttr = extractAttribute(openingTag, 'class'); |
| 126 | + if (!classAttr) {return '';} |
| 127 | + return classAttr |
| 128 | + .trim() |
| 129 | + .split(/\s+/) |
| 130 | + .filter(cls => cls && !CSS_IN_JS_REGEX.test(cls)) |
| 131 | + .sort() |
| 132 | + .join(' '); |
| 133 | +} |
| 134 | + |
| 135 | +// --- Extract path-only from URL (strip query string and fragment) --- |
| 136 | +function extractPathOnly(url) { |
| 137 | + if (!url) {return '';} |
| 138 | + try { |
| 139 | + return url.split('?')[0].split('#')[0]; |
| 140 | + } catch { |
| 141 | + return url; |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +// --- Extract visual (non-positional) inline styles, sorted --- |
| 146 | +function extractVisualStyles(openingTag) { |
| 147 | + const styleAttr = extractAttribute(openingTag, 'style'); |
| 148 | + if (!styleAttr) {return '';} |
| 149 | + return styleAttr |
| 150 | + .split(';') |
| 151 | + .map(s => s.trim()) |
| 152 | + .filter(s => s) |
| 153 | + .map(s => { |
| 154 | + const colonIdx = s.indexOf(':'); |
| 155 | + if (colonIdx === -1) {return null;} |
| 156 | + const prop = s.slice(0, colonIdx).trim().toLowerCase(); |
| 157 | + const val = s.slice(colonIdx + 1).trim(); |
| 158 | + return { prop, val }; |
| 159 | + }) |
| 160 | + .filter(s => s && !POSITIONAL_PROPERTIES.has(s.prop)) |
| 161 | + .sort((a, b) => a.prop.localeCompare(b.prop)) |
| 162 | + .map(s => `${s.prop}:${s.val}`) |
| 163 | + .join(';'); |
| 164 | +} |
| 165 | + |
| 166 | +// --- Extract width/height from attributes or inline style --- |
| 167 | +function extractDimension(openingTag, dimName) { |
| 168 | + // First check HTML attribute |
| 169 | + const attrVal = extractAttribute(openingTag, dimName); |
| 170 | + if (attrVal) {return attrVal;} |
| 171 | + // Fallback to inline style |
| 172 | + const styleAttr = extractAttribute(openingTag, 'style'); |
| 173 | + if (!styleAttr) {return '';} |
| 174 | + const regex = new RegExp(`(?:^|;)\\s*${dimName}\\s*:\\s*([^;]+)`, 'i'); |
| 175 | + const match = styleAttr.match(regex); |
| 176 | + return match ? match[1].trim() : ''; |
| 177 | +} |
| 178 | + |
| 179 | +// --- Extract textContent by stripping all HTML tags --- |
| 180 | +function extractTextContent(outerHTML) { |
| 181 | + return decodeEntities( |
| 182 | + outerHTML |
| 183 | + .replace(/<[^>]*>/g, '') |
| 184 | + .replace(/\s+/g, ' ') |
| 185 | + .trim() |
| 186 | + ); |
| 187 | +} |
| 188 | + |
| 189 | +// --- Extract direct child tags using tag-depth tracking --- |
| 190 | +function extractDirectChildren(outerHTML) { |
| 191 | + const tagName = extractTagName(outerHTML); |
| 192 | + if (!tagName || VOID_ELEMENTS.has(tagName)) { |
| 193 | + return { childCount: 0, childTags: '' }; |
| 194 | + } |
| 195 | + |
| 196 | + // Strip the outermost opening and closing tags |
| 197 | + const openTagEnd = outerHTML.indexOf('>'); |
| 198 | + if (openTagEnd === -1) {return { childCount: 0, childTags: '' };} |
| 199 | + |
| 200 | + // Check for self-closing tag |
| 201 | + if (outerHTML[openTagEnd - 1] === '/') { |
| 202 | + return { childCount: 0, childTags: '' }; |
| 203 | + } |
| 204 | + |
| 205 | + const closingTag = `</${tagName}>`; |
| 206 | + const closingIdx = outerHTML.lastIndexOf(closingTag); |
| 207 | + if (closingIdx === -1) {return { childCount: 0, childTags: '' };} |
| 208 | + |
| 209 | + const inner = outerHTML.slice(openTagEnd + 1, closingIdx); |
| 210 | + if (!inner.trim()) {return { childCount: 0, childTags: '' };} |
| 211 | + |
| 212 | + // Track depth to find direct children (depth 0 tags) |
| 213 | + const children = []; |
| 214 | + let depth = 0; |
| 215 | + const tagRegex = /<\/?(\w+)([^>]*?)(\/?)\s*>/g; |
| 216 | + let match = tagRegex.exec(inner); |
| 217 | + |
| 218 | + while (match !== null) { |
| 219 | + const isClosing = match[0][1] === '/'; |
| 220 | + const name = match[1].toLowerCase(); |
| 221 | + const isSelfClosing = match[3] === '/' || VOID_ELEMENTS.has(name); |
| 222 | + |
| 223 | + if (isClosing) { |
| 224 | + depth -= 1; |
| 225 | + } else if (depth === 0) { |
| 226 | + children.push(name); |
| 227 | + if (!isSelfClosing) { |
| 228 | + depth += 1; |
| 229 | + } |
| 230 | + } else if (!isSelfClosing) { |
| 231 | + depth += 1; |
| 232 | + } |
| 233 | + match = tagRegex.exec(inner); |
| 234 | + } |
| 235 | + |
| 236 | + return { |
| 237 | + childCount: children.length, |
| 238 | + childTags: [...new Set(children)].sort().join(',') |
| 239 | + }; |
| 240 | +} |
| 241 | + |
| 242 | +// --- Main fingerprint computation --- |
| 243 | +// [a11y-core]: html fingerprint hash for dedup/grouping |
| 244 | +function computeFingerprintHash(outerHTML) { |
| 245 | + if (!outerHTML || typeof outerHTML !== 'string') {return null;} |
| 246 | + |
| 247 | + try { |
| 248 | + const openingTag = extractOpeningTag(outerHTML); |
| 249 | + if (!openingTag) {return null;} |
| 250 | + |
| 251 | + // Strip data-percy-* and data-* attributes for consistent fingerprinting |
| 252 | + const cleanTag = openingTag |
| 253 | + .replace(/\s*data-[a-zA-Z-]+="[^"]*"/g, '') |
| 254 | + .replace(/\s*data-[a-zA-Z-]+='[^']*'/g, ''); |
| 255 | + |
| 256 | + const { childCount, childTags } = extractDirectChildren(outerHTML); |
| 257 | + |
| 258 | + // 13-field fingerprint spec |
| 259 | + const fields = [ |
| 260 | + extractTagName(outerHTML), |
| 261 | + extractAttribute(cleanTag, 'role'), |
| 262 | + extractAriaAttributes(cleanTag), |
| 263 | + extractAttribute(cleanTag, 'type'), |
| 264 | + extractPathOnly(extractAttribute(cleanTag, 'href')), |
| 265 | + extractPathOnly(extractAttribute(cleanTag, 'src')), |
| 266 | + extractFilteredClassList(cleanTag), |
| 267 | + extractVisualStyles(cleanTag), |
| 268 | + extractDimension(cleanTag, 'width'), |
| 269 | + extractDimension(cleanTag, 'height'), |
| 270 | + extractTextContent(outerHTML), |
| 271 | + String(childCount), |
| 272 | + childTags |
| 273 | + ]; |
| 274 | + |
| 275 | + const fingerprintString = fields.join('||'); |
| 276 | + return fnvHash(fingerprintString); |
| 277 | + } catch { |
| 278 | + return null; |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +export default computeFingerprintHash; |
0 commit comments