Skip to content

Commit 59b8555

Browse files
author
Abhishek Bindra
committed
feat(fingerprint): add computeFingerprintHash utility for htmlHash
Single outerHTML string-based fingerprint hash for violation node dedup. Exported as default from utils/fingerprint.js, registered in utils/index.js.
1 parent c6cdc46 commit 59b8555

3 files changed

Lines changed: 403 additions & 0 deletions

File tree

lib/core/utils/fingerprint.js

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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(/&amp;/g, '&')
81+
.replace(/&lt;/g, '<')
82+
.replace(/&gt;/g, '>')
83+
.replace(/&quot;/g, '"')
84+
.replace(/&#39;/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;

lib/core/utils/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export { default as queue } from './queue';
8585
export { default as respondable } from './respondable';
8686
export { default as ruleShouldRun } from './rule-should-run';
8787
export { default as filterHtmlAttrs } from './filter-html-attrs';
88+
export { default as computeFingerprintHash } from './fingerprint';
8889
export { default as select } from './select';
8990
export { default as sendCommandToFrame } from './send-command-to-frame';
9091
export { default as serializeError } from './serialize-error';

0 commit comments

Comments
 (0)