import type { Page } from 'playwright'; // --------------------------------------------------------------------------- // Internal: FNV-1a 32-bit SimHash // --------------------------------------------------------------------------- function fnv1a32(token: string): number { let h = 2166136261; for (let i = 0; i < token.length; i++) { h = Math.imul(h ^ token.charCodeAt(i), 16777619); h = h >>> 0; } return h; } export function simHash32(text: string): number { const tokens = text .split(/\W+/) .filter((t) => t.length > 2) .slice(0, 500); const vec = new Array(32).fill(0); for (const token of tokens) { const h = fnv1a32(token); for (let b = 0; b < 32; b++) { vec[b] += (h >>> b) & 1 ? 1 : -1; } } let result = 0; for (let b = 0; b < 32; b++) { if (vec[b] > 0) result |= 1 << b; } return result >>> 0; } export function hammingDistance32(a: number, b: number): number { let n = (a ^ b) >>> 0; n = n - ((n >>> 1) & 0x55555555); n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); n = (n + (n >>> 4)) & 0x0f0f0f0f; return Math.imul(n, 0x01010101) >>> 24; } // --------------------------------------------------------------------------- // URL normalisation // --------------------------------------------------------------------------- const STRIP_PARAMS = new Set([ 'utm_source', 'utm_medium', 'utm_campaign', 'ref', '_ga', 'fbclid', 'session_id', ]); export function normalizeUrl(raw: string): string { try { const u = new URL(raw); u.hash = ''; const keep: Array<[string, string]> = []; u.searchParams.forEach((v, k) => { if (!STRIP_PARAMS.has(k)) keep.push([k, v]); }); keep.sort((a, b) => a[0].localeCompare(b[0])); u.search = keep.length ? '?' + keep.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') : ''; return u.toString(); } catch { return raw; } } export function computeStateId(normalizedUrl: string, hash: number): string { let path = normalizedUrl.slice(0, 40).replace(/[^a-zA-Z0-9]/g, '_'); const hex = hash.toString(16).padStart(8, '0'); return `${path}_${hex}`; } // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface UIStateNode { id: string; url: string; normalizedUrl: string; title: string; interactiveElementCount: number; domFingerprint: string; simHashValue: number; visitedAt: string; } export interface StateTransition { fromId: string; toId: string; action: string; label: string; triggeredAt: string; } export interface CoverageReport { totalStates: number; totalTransitions: number; uniqueUrls: number; interactiveElementsTotal: number; estimatedCoveragePercent: number; } // --------------------------------------------------------------------------- // Page fingerprinting // --------------------------------------------------------------------------- export async function fingerprintPage( page: Page, visitedAt: string, ): Promise<{ fingerprint: string; visibleText: string; interactiveCount: number; simHashValue: number; }> { let visibleText = ''; let interactiveCount = 0; try { visibleText = await page.evaluate(() => { const el = document.querySelector('main') ?? document.body; return (el as HTMLElement).innerText ?? ''; }); } catch { visibleText = ''; } try { interactiveCount = await page.evaluate(() => document.querySelectorAll( 'a[href], button, input, select, textarea, [role="button"], [role="link"], [tabindex]', ).length, ); } catch { interactiveCount = 0; } const simHashValue = simHash32(visibleText + visitedAt.slice(0, 10)); const fingerprint = simHashValue.toString(16).padStart(8, '0'); return { fingerprint, visibleText, interactiveCount, simHashValue }; } // --------------------------------------------------------------------------- // ScreenTransitionGraph // --------------------------------------------------------------------------- export class ScreenTransitionGraph { private states = new Map(); private transitions: StateTransition[] = []; /** Maps simHash value → array of state ids with that hash (for near-dup lookup) */ private hashIndex = new Map(); // ------------------------------------------------------------------------- // State management // ------------------------------------------------------------------------- addState(node: UIStateNode): { added: boolean; duplicateOf?: string } { if (this.states.has(node.id)) { return { added: false, duplicateOf: node.id }; } // Check near-duplicate via hamming distance across all indexed hashes for (const [indexedHash, ids] of this.hashIndex) { if (hammingDistance32(node.simHashValue, indexedHash) < 3) { return { added: false, duplicateOf: ids[0] }; } } this.states.set(node.id, node); const bucket = this.hashIndex.get(node.simHashValue); if (bucket) { bucket.push(node.id); } else { this.hashIndex.set(node.simHashValue, [node.id]); } return { added: true }; } addTransition( fromId: string, toId: string, action: string, label: string, triggeredAt: string, ): void { this.transitions.push({ fromId, toId, action, label, triggeredAt }); } isDuplicate(url: string, visibleText: string, threshold = 3): boolean { const normalized = normalizeUrl(url); const hash = simHash32(visibleText); for (const [indexedHash] of this.hashIndex) { if (hammingDistance32(hash, indexedHash) < threshold) return true; } // Also check exact normalized URL match for (const node of this.states.values()) { if (node.normalizedUrl === normalized) return true; } return false; } // ------------------------------------------------------------------------- // Reporting // ------------------------------------------------------------------------- getCoverageReport(): CoverageReport { const totalStates = this.states.size; const totalTransitions = this.transitions.length; const uniqueUrls = new Set( Array.from(this.states.values()).map((n) => n.normalizedUrl), ).size; const interactiveElementsTotal = Array.from(this.states.values()).reduce( (sum, n) => sum + n.interactiveElementCount, 0, ); const estimatedCoveragePercent = Math.min( 100, (totalTransitions / Math.max(1, totalStates)) * 50, ); return { totalStates, totalTransitions, uniqueUrls, interactiveElementsTotal, estimatedCoveragePercent, }; } // ------------------------------------------------------------------------- // Serialisation // ------------------------------------------------------------------------- toJSON(): { states: UIStateNode[]; transitions: StateTransition[] } { return { states: Array.from(this.states.values()), transitions: this.transitions, }; } static fromJSON(data: { states: UIStateNode[]; transitions: StateTransition[] }): ScreenTransitionGraph { const graph = new ScreenTransitionGraph(); for (const node of data.states) { graph.states.set(node.id, node); const bucket = graph.hashIndex.get(node.simHashValue); if (bucket) { bucket.push(node.id); } else { graph.hashIndex.set(node.simHashValue, [node.id]); } } graph.transitions = [...data.transitions]; return graph; } // ------------------------------------------------------------------------- // Accessors // ------------------------------------------------------------------------- getState(id: string): UIStateNode | undefined { return this.states.get(id); } getAllStates(): UIStateNode[] { return Array.from(this.states.values()); } getTransitionsFrom(id: string): StateTransition[] { return this.transitions.filter((t) => t.fromId === id); } }