fix: prevent silent passing on missing topology data + visible cleanup failures

predicates.ts (missing-fact discipline):
  - getTopologyValueBySubject: return -1 sentinel instead of ?? 0
    when topology data is missing/unset. NaN and negative values
    also treated as missing. Previously returned 0 which was
    indistinguishable from root subject ID.
  - clippedByPredicate: return 'indeterminate' when clippingRoot < 0
  - inStackingContextPredicate: return 'indeterminate' when sc < 0
    or refSC < 0 (both subject and reference). Previously treated
    missing data as false — a silent wrong answer.
  - attachedToScrollContainerPredicate: return 'indeterminate' when
    scrollContainer < 0
  - escapeClippingChainOfPredicate: return 'indeterminate' when
    clippingRoot < 0

extraction.ts (cleanup visibility):
  - Promote fast-geometry and CDP cleanup failures from console.debug
    (invisible during test execution) to console.warn. Contaminated
    pages are now diagnosable without debug-log inspection.

658 tests pass.
This commit is contained in:
John Dvorak
2026-05-22 13:55:11 -07:00
parent 1a8f308f71
commit 45b5575e53
2 changed files with 13 additions and 3 deletions
@@ -747,7 +747,8 @@ export async function extractWorldFastGeometry(
el.removeAttribute('data-imhotep-runtime-id') el.removeAttribute('data-imhotep-runtime-id')
} }
}).catch((err) => { }).catch((err) => {
console.debug('[imhotep-playwright] fast-geometry cleanup evaluate failed:', err instanceof Error ? err.message : err) // eslint-disable-next-line no-console
console.warn('[imhotep-playwright] fast-geometry cleanup failed:', err instanceof Error ? err.message : err)
}) })
} }
} }
@@ -933,7 +934,8 @@ export async function extractWorldCdp(
el.removeAttribute('data-imhotep-runtime-id') el.removeAttribute('data-imhotep-runtime-id')
} }
}).catch((err) => { }).catch((err) => {
console.debug('[imhotep-playwright] CDP cleanup evaluate failed:', err instanceof Error ? err.message : err) // eslint-disable-next-line no-console
console.warn('[imhotep-playwright] CDP cleanup failed:', err instanceof Error ? err.message : err)
}) })
await sessionManager.detach() await sessionManager.detach()
} }
+9 -1
View File
@@ -195,7 +195,10 @@ function getSubjectIndex(world: GeometryWorld, subjectId: number): number {
function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number { function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number {
const idx = getSubjectIndex(world, subjectId); const idx = getSubjectIndex(world, subjectId);
return idx >= 0 ? values[idx] ?? 0 : 0; if (idx < 0 || idx >= values.length) return -1;
const v = values[idx];
// NaN or negative values indicate unset topology; treat as missing.
return (v === undefined || Number.isNaN(v) || v < 0) ? -1 : v;
} }
function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number { function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number {
@@ -782,6 +785,7 @@ export const clippedByPredicate: PredicateEvaluator = {
} }
// Simplified: check if subject's clipping root is the reference // Simplified: check if subject's clipping root is the reference
const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
if (clipRoot < 0) return makePredicateResult('indeterminate');
const pass = clipRoot === referenceId; const pass = clipRoot === referenceId;
// Determine clip kind from the clipping table entry for the reference. // Determine clip kind from the clipping table entry for the reference.
// Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto // Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto
@@ -807,11 +811,13 @@ export const inStackingContextPredicate: PredicateEvaluator = {
return makePredicateResult('indeterminate'); return makePredicateResult('indeterminate');
} }
const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf); const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf);
if (sc < 0) return makePredicateResult('indeterminate');
const subjectHasSC = sc > 0; const subjectHasSC = sc > 0;
if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) { if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) {
const referenceId = tuple[1]; const referenceId = tuple[1];
const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf); const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf);
if (refSC < 0) return makePredicateResult('indeterminate');
const pass = subjectHasSC && refSC > 0 && sc === refSC; const pass = subjectHasSC && refSC > 0 && sc === refSC;
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]); return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]);
} }
@@ -829,6 +835,7 @@ export const attachedToScrollContainerPredicate: PredicateEvaluator = {
return makePredicateResult('indeterminate'); return makePredicateResult('indeterminate');
} }
const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf); const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf);
if (scrollContainer < 0) return makePredicateResult('indeterminate');
const pass = scrollContainer > 0 && scrollContainer === referenceId; const pass = scrollContainer > 0 && scrollContainer === referenceId;
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]); return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]);
}, },
@@ -854,6 +861,7 @@ export const escapeClippingChainOfPredicate: PredicateEvaluator = {
const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom); const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom);
const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom; const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom;
const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
if (clippingRoot < 0) return makePredicateResult('indeterminate', { hasClippingRoot: 0 }, [subjectId, referenceId]);
const clippedByReference = clippingRoot === referenceId; const clippedByReference = clippingRoot === referenceId;
const pass = overflow > 0 && !clippedByReference && ancestry !== false; const pass = overflow > 0 && !clippedByReference && ancestry !== false;
const metrics: Record<string, number> = { const metrics: Record<string, number> = {