diff --git a/package-lock.json b/package-lock.json index 78694626..4aa0fb61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sentienceapi", - "version": "0.92.2", + "version": "0.92.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sentienceapi", - "version": "0.92.2", + "version": "0.92.3", "license": "(MIT OR Apache-2.0)", "dependencies": { "playwright": "^1.40.0", diff --git a/src/canonicalization.ts b/src/canonicalization.ts new file mode 100644 index 00000000..8335a139 --- /dev/null +++ b/src/canonicalization.ts @@ -0,0 +1,208 @@ +/** + * Shared canonicalization utilities for snapshot comparison and indexing. + * + * This module provides consistent normalization functions used by both: + * - tracing/indexer.ts (for computing stable digests) + * - snapshot-diff.ts (for computing diff_status labels) + * + * By sharing these helpers, we ensure consistent behavior: + * - Same text normalization (whitespace, case, length) + * - Same bbox rounding (2px precision) + * - Same change detection thresholds + */ + +export interface BBox { + x: number; + y: number; + width: number; + height: number; +} + +export interface VisualCues { + is_primary?: boolean; + is_clickable?: boolean; +} + +export interface ElementData { + id?: number; + role?: string; + text?: string | null; + bbox?: BBox; + visual_cues?: VisualCues; + is_primary?: boolean; + is_clickable?: boolean; +} + +export interface CanonicalElement { + id: number | undefined; + role: string; + text_norm: string; + bbox: BBox; + is_primary: boolean; + is_clickable: boolean; +} + +/** + * Normalize text for canonical comparison. + * + * Transforms: + * - Trims leading/trailing whitespace + * - Collapses internal whitespace to single spaces + * - Lowercases + * - Caps length + * + * @param text - Input text (may be undefined/null) + * @param maxLen - Maximum length to retain (default: 80) + * @returns Normalized text string (empty string if input is falsy) + * + * @example + * normalizeText(" Hello World ") // "hello world" + * normalizeText(undefined) // "" + */ +export function normalizeText(text: string | undefined | null, maxLen: number = 80): string { + if (!text) return ''; + + // Trim and collapse whitespace + let normalized = text.split(/\s+/).join(' ').trim(); + + // Lowercase + normalized = normalized.toLowerCase(); + + // Cap length + if (normalized.length > maxLen) { + normalized = normalized.substring(0, maxLen); + } + + return normalized; +} + +/** + * Round bbox coordinates to reduce noise. + * + * Snaps coordinates to grid of `precision` pixels to ignore + * sub-pixel rendering differences. + * + * @param bbox - Bounding box with x, y, width, height + * @param precision - Grid size in pixels (default: 2) + * @returns Rounded bbox with integer coordinates + * + * @example + * roundBBox({x: 101, y: 203, width: 50, height: 25}) + * // {x: 100, y: 202, width: 50, height: 24} + */ +export function roundBBox(bbox: Partial, precision: number = 2): BBox { + return { + x: Math.round((bbox.x || 0) / precision) * precision, + y: Math.round((bbox.y || 0) / precision) * precision, + width: Math.round((bbox.width || 0) / precision) * precision, + height: Math.round((bbox.height || 0) / precision) * precision, + }; +} + +/** + * Check if two bboxes are equal within a threshold. + * + * @param bbox1 - First bounding box + * @param bbox2 - Second bounding box + * @param threshold - Maximum allowed difference in pixels (default: 5.0) + * @returns True if all bbox properties differ by less than threshold + */ +export function bboxEqual( + bbox1: Partial, + bbox2: Partial, + threshold: number = 5.0 +): boolean { + return ( + Math.abs((bbox1.x || 0) - (bbox2.x || 0)) <= threshold && + Math.abs((bbox1.y || 0) - (bbox2.y || 0)) <= threshold && + Math.abs((bbox1.width || 0) - (bbox2.width || 0)) <= threshold && + Math.abs((bbox1.height || 0) - (bbox2.height || 0)) <= threshold + ); +} + +/** + * Check if two bboxes differ beyond the threshold. + * + * This is the inverse of bboxEqual, provided for semantic clarity + * in diff detection code. + * + * @param bbox1 - First bounding box + * @param bbox2 - Second bounding box + * @param threshold - Maximum allowed difference in pixels (default: 5.0) + * @returns True if any bbox property differs by more than threshold + */ +export function bboxChanged( + bbox1: Partial, + bbox2: Partial, + threshold: number = 5.0 +): boolean { + return !bboxEqual(bbox1, bbox2, threshold); +} + +/** + * Create canonical representation of an element for comparison/hashing. + * + * Extracts and normalizes the fields that matter for identity: + * - id, role, normalized text, rounded bbox + * - is_primary, is_clickable from visual_cues + * + * @param elem - Raw element object + * @returns Canonical element object with normalized fields + */ +export function canonicalizeElement(elem: ElementData): CanonicalElement { + // Extract is_primary and is_clickable from visual_cues if present + const visualCues = elem.visual_cues || {}; + const isPrimary = + typeof visualCues === 'object' && visualCues !== null + ? visualCues.is_primary || false + : elem.is_primary || false; + const isClickable = + typeof visualCues === 'object' && visualCues !== null + ? visualCues.is_clickable || false + : elem.is_clickable || false; + + return { + id: elem.id, + role: elem.role || '', + text_norm: normalizeText(elem.text), + bbox: roundBBox(elem.bbox || { x: 0, y: 0, width: 0, height: 0 }), + is_primary: isPrimary, + is_clickable: isClickable, + }; +} + +/** + * Check if two elements have equal content (ignoring position). + * + * Compares normalized text, role, and visual cues. + * + * @param elem1 - First element (raw or canonical) + * @param elem2 - Second element (raw or canonical) + * @returns True if content is equal after normalization + */ +export function contentEqual(elem1: ElementData, elem2: ElementData): boolean { + // Normalize both elements + const c1 = canonicalizeElement(elem1); + const c2 = canonicalizeElement(elem2); + + return ( + c1.role === c2.role && + c1.text_norm === c2.text_norm && + c1.is_primary === c2.is_primary && + c1.is_clickable === c2.is_clickable + ); +} + +/** + * Check if two elements have different content (ignoring position). + * + * This is the inverse of contentEqual, provided for semantic clarity + * in diff detection code. + * + * @param elem1 - First element + * @param elem2 - Second element + * @returns True if content differs after normalization + */ +export function contentChanged(elem1: ElementData, elem2: ElementData): boolean { + return !contentEqual(elem1, elem2); +} diff --git a/src/snapshot-diff.ts b/src/snapshot-diff.ts index 793655a0..0f8d4c27 100644 --- a/src/snapshot-diff.ts +++ b/src/snapshot-diff.ts @@ -1,57 +1,43 @@ /** * Snapshot comparison utilities for diff_status detection. * Implements change detection logic for the Diff Overlay feature. + * + * Uses shared canonicalization helpers from canonicalization.ts to ensure + * consistent comparison behavior with tracing/indexer.ts. */ +import { bboxChanged, contentChanged, ElementData } from './canonicalization'; import { Element, Snapshot } from './types'; -export class SnapshotDiff { - /** - * Check if element's bounding box has changed significantly. - * @param el1 - First element - * @param el2 - Second element - * @param threshold - Position change threshold in pixels (default: 5.0) - * @returns True if position or size changed beyond threshold - */ - private static hasBboxChanged(el1: Element, el2: Element, threshold: number = 5.0): boolean { - return ( - Math.abs(el1.bbox.x - el2.bbox.x) > threshold || - Math.abs(el1.bbox.y - el2.bbox.y) > threshold || - Math.abs(el1.bbox.width - el2.bbox.width) > threshold || - Math.abs(el1.bbox.height - el2.bbox.height) > threshold - ); - } - - /** - * Check if element's content has changed. - * @param el1 - First element - * @param el2 - Second element - * @returns True if text, role, or visual properties changed - */ - private static hasContentChanged(el1: Element, el2: Element): boolean { - // Compare text content - if (el1.text !== el2.text) { - return true; - } - - // Compare role - if (el1.role !== el2.role) { - return true; - } - - // Compare visual cues - if (el1.visual_cues.is_primary !== el2.visual_cues.is_primary) { - return true; - } - if (el1.visual_cues.is_clickable !== el2.visual_cues.is_clickable) { - return true; - } - - return false; - } +/** + * Convert Element to ElementData for canonicalization helpers. + */ +function elementToData(el: Element): ElementData { + return { + id: el.id, + role: el.role, + text: el.text, + bbox: { + x: el.bbox.x, + y: el.bbox.y, + width: el.bbox.width, + height: el.bbox.height, + }, + visual_cues: { + is_primary: el.visual_cues.is_primary, + is_clickable: el.visual_cues.is_clickable, + }, + }; +} +export class SnapshotDiff { /** * Compare current snapshot with previous and set diff_status on elements. + * + * Uses canonicalized comparisons: + * - Text is normalized (trimmed, collapsed whitespace, lowercased) + * - Bbox is rounded to 2px grid to ignore sub-pixel differences + * * @param current - Current snapshot * @param previous - Previous snapshot (undefined if this is the first snapshot) * @returns List of elements with diff_status set (includes REMOVED elements from previous) @@ -83,25 +69,29 @@ export class SnapshotDiff { diff_status: 'ADDED', }); } else { - // Element existed before - check for changes + // Element existed before - check for changes using canonicalized comparisons const prevEl = previousById.get(el.id)!; - const bboxChanged = SnapshotDiff.hasBboxChanged(el, prevEl); - const contentChanged = SnapshotDiff.hasContentChanged(el, prevEl); + // Convert to ElementData for canonicalization helpers + const elData = elementToData(el); + const prevElData = elementToData(prevEl); + + const hasBboxChanged = bboxChanged(elData.bbox!, prevElData.bbox!); + const hasContentChanged = contentChanged(elData, prevElData); - if (bboxChanged && contentChanged) { + if (hasBboxChanged && hasContentChanged) { // Both position and content changed - mark as MODIFIED result.push({ ...el, diff_status: 'MODIFIED', }); - } else if (bboxChanged) { + } else if (hasBboxChanged) { // Only position changed - mark as MOVED result.push({ ...el, diff_status: 'MOVED', }); - } else if (contentChanged) { + } else if (hasContentChanged) { // Only content changed - mark as MODIFIED result.push({ ...el, diff --git a/src/tracing/indexer.ts b/src/tracing/indexer.ts index 2c38e358..fdc8e0ab 100644 --- a/src/tracing/indexer.ts +++ b/src/tracing/indexer.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as crypto from 'crypto'; import * as path from 'path'; +import { canonicalizeElement } from '../canonicalization'; import { TraceIndex, StepIndex, @@ -16,38 +17,6 @@ import { StepStatus, } from './index-schema'; -/** - * Normalize text for digest: trim, collapse whitespace, lowercase, cap length - */ -function normalizeText(text: string | undefined, maxLen: number = 80): string { - if (!text) return ''; - - // Trim and collapse whitespace - let normalized = text.split(/\s+/).join(' ').trim(); - - // Lowercase - normalized = normalized.toLowerCase(); - - // Cap length - if (normalized.length > maxLen) { - normalized = normalized.substring(0, maxLen); - } - - return normalized; -} - -/** - * Round bbox coordinates to reduce noise (default: 2px precision) - */ -function roundBBox(bbox: any, precision: number = 2): any { - return { - x: Math.round((bbox.x || 0) / precision) * precision, - y: Math.round((bbox.y || 0) / precision) * precision, - width: Math.round((bbox.width || 0) / precision) * precision, - height: Math.round((bbox.height || 0) / precision) * precision, - }; -} - /** * Compute stable digest of snapshot for diffing */ @@ -56,28 +25,8 @@ function computeSnapshotDigest(snapshotData: any): string { const viewport = snapshotData.viewport || {}; const elements = snapshotData.elements || []; - // Canonicalize elements - const canonicalElements = elements.map((elem: any) => { - // Extract is_primary and is_clickable from visual_cues if present - const visualCues = elem.visual_cues || {}; - const isPrimary = - typeof visualCues === 'object' && visualCues !== null - ? visualCues.is_primary || false - : elem.is_primary || false; - const isClickable = - typeof visualCues === 'object' && visualCues !== null - ? visualCues.is_clickable || false - : elem.is_clickable || false; - - return { - id: elem.id, - role: elem.role || '', - text_norm: normalizeText(elem.text), - bbox: roundBBox(elem.bbox || { x: 0, y: 0, width: 0, height: 0 }), - is_primary: isPrimary, - is_clickable: isClickable, - }; - }); + // Canonicalize elements using shared helper + const canonicalElements = elements.map((elem: any) => canonicalizeElement(elem)); // Sort by element id for determinism canonicalElements.sort((a: { id?: number }, b: { id?: number }) => (a.id || 0) - (b.id || 0));