@riddledc/riddle-proof 0.7.37 → 0.7.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -217,6 +217,29 @@ The check records the visible text sequence for the selector and passes when
217
217
  the expected texts appear in that order as a subsequence. This is less brittle
218
218
  than matching one large body-text regex when only row or card order matters.
219
219
 
220
+ Use `frame_text_visible` and `frame_no_horizontal_overflow` for embedded app,
221
+ game, or preview surfaces that render inside iframes:
222
+
223
+ ```json
224
+ [
225
+ {
226
+ "type": "frame_text_visible",
227
+ "selector": ".game-player-root iframe",
228
+ "text": "Start Game"
229
+ },
230
+ {
231
+ "type": "frame_no_horizontal_overflow",
232
+ "selector": ".game-player-root iframe",
233
+ "max_overflow_px": 1
234
+ }
235
+ ]
236
+ ```
237
+
238
+ Frame checks capture each matching iframe's URL, title, compact text sample,
239
+ scroll width, client width, measured horizontal overflow, and top visible
240
+ overflow offenders. This keeps embedded-player audits in profile mode instead
241
+ of requiring bespoke iframe inspection scripts.
242
+
220
243
  Use the `route_inventory` check for source-page route coverage audits where a
221
244
  navigation surface must expose a known set of routes and each route must load
222
245
  both directly and through real link clicks:
@@ -15,6 +15,8 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
15
15
  "selector_visible",
16
16
  "selector_count_at_least",
17
17
  "selector_text_order",
18
+ "frame_text_visible",
19
+ "frame_no_horizontal_overflow",
18
20
  "text_visible",
19
21
  "text_absent",
20
22
  "route_inventory",
@@ -313,6 +315,12 @@ function normalizeCheck(input, index) {
313
315
  if ((type === "selector_visible" || type === "selector_count_at_least") && !stringValue(input.selector)) {
314
316
  throw new Error(`checks[${index}] ${type} requires selector.`);
315
317
  }
318
+ if ((type === "frame_text_visible" || type === "frame_no_horizontal_overflow") && !stringValue(input.selector)) {
319
+ throw new Error(`checks[${index}] ${type} requires selector.`);
320
+ }
321
+ if (type === "frame_text_visible" && !stringValue(input.text) && !stringValue(input.pattern)) {
322
+ throw new Error(`checks[${index}] frame_text_visible requires text or pattern.`);
323
+ }
316
324
  if ((type === "text_visible" || type === "text_absent") && !stringValue(input.text) && !stringValue(input.pattern)) {
317
325
  throw new Error(`checks[${index}] ${type} requires text or pattern.`);
318
326
  }
@@ -463,6 +471,22 @@ function textOrderMatch(texts, expectedTexts) {
463
471
  }
464
472
  return { matched: true, positions };
465
473
  }
474
+ function frameEvidenceForSelector(viewport, selector) {
475
+ const container = viewport.frames?.[selector];
476
+ if (!isRecord(container)) return [];
477
+ const frames = Array.isArray(container.frames) ? container.frames : [];
478
+ return frames.filter((frame) => isRecord(frame));
479
+ }
480
+ function frameTextSample(frame) {
481
+ const parts = [
482
+ frame.title,
483
+ frame.text_sample,
484
+ frame.body_text_sample,
485
+ frame.body_text,
486
+ frame.text
487
+ ];
488
+ return parts.map((part) => String(part || "")).filter(Boolean).join(" ");
489
+ }
466
490
  function summarizeRouteInventory(viewport, inventory) {
467
491
  const directRoutes = Array.isArray(inventory.direct_routes) ? inventory.direct_routes : [];
468
492
  const clickthroughs = Array.isArray(inventory.clickthroughs) ? inventory.clickthroughs : [];
@@ -627,6 +651,67 @@ function assessCheckFromEvidence(check, evidence) {
627
651
  message: failed ? `Selector ${key} text order failed in ${failed} viewport(s).` : void 0
628
652
  };
629
653
  }
654
+ if (check.type === "frame_text_visible") {
655
+ const key = selectorKey(check);
656
+ const results = viewports.map((viewport) => {
657
+ const frames = frameEvidenceForSelector(viewport, key);
658
+ const matches = frames.map((frame, index) => ({ index, frame, matched: matchText(frameTextSample(frame), check) })).filter((item) => item.matched);
659
+ return {
660
+ viewport: viewport.name,
661
+ frame_count: frames.length,
662
+ matched_count: matches.length,
663
+ matched: matches.length > 0,
664
+ urls: frames.map((frame) => String(frame.url || "")).filter(Boolean).slice(0, 10),
665
+ samples: matches.map((item) => frameTextSample(item.frame).replace(/\s+/g, " ").trim().slice(0, 240)).slice(0, 3)
666
+ };
667
+ });
668
+ const failed = results.filter((result) => !result.matched).length;
669
+ return {
670
+ type: check.type,
671
+ label: checkLabel(check),
672
+ status: failed ? "failed" : "passed",
673
+ evidence: {
674
+ selector: key,
675
+ text: check.text || null,
676
+ pattern: check.pattern || null,
677
+ viewports: results.map((result) => toJsonValue(result))
678
+ },
679
+ message: failed ? `Frame selector ${key} did not contain expected text in ${failed} viewport(s).` : void 0
680
+ };
681
+ }
682
+ if (check.type === "frame_no_horizontal_overflow") {
683
+ const key = selectorKey(check);
684
+ const maxOverflow = check.max_overflow_px ?? 4;
685
+ const results = viewports.map((viewport) => {
686
+ const frames = frameEvidenceForSelector(viewport, key);
687
+ const frameOverflows = frames.map((frame, index) => ({
688
+ index,
689
+ url: String(frame.url || ""),
690
+ overflow_px: horizontalBoundsOverflowPx(frame),
691
+ offender_count: boundsOffendersForEvidence(frame).length
692
+ }));
693
+ const maxFrameOverflow = frameOverflows.reduce((max, frame) => Math.max(max, frame.overflow_px), 0);
694
+ return {
695
+ viewport: viewport.name,
696
+ frame_count: frames.length,
697
+ max_overflow_px: roundPixels(maxFrameOverflow),
698
+ failed_frame_count: frameOverflows.filter((frame) => frame.overflow_px > maxOverflow).length,
699
+ frames: frameOverflows.map((frame) => toJsonValue(frame))
700
+ };
701
+ });
702
+ const failed = results.filter((result) => result.frame_count < 1 || result.failed_frame_count > 0).length;
703
+ return {
704
+ type: check.type,
705
+ label: checkLabel(check),
706
+ status: failed ? "failed" : "passed",
707
+ evidence: {
708
+ selector: key,
709
+ max_overflow_px: maxOverflow,
710
+ viewports: results.map((result) => toJsonValue(result))
711
+ },
712
+ message: failed ? `Frame selector ${key} overflow exceeded ${maxOverflow}px or was missing in ${failed} viewport(s).` : void 0
713
+ };
714
+ }
630
715
  if (check.type === "text_visible" || check.type === "text_absent") {
631
716
  const key = textKey(check);
632
717
  const expectedVisible = check.type === "text_visible";
@@ -1002,6 +1087,21 @@ function textOrderMatch(texts, expectedTexts) {
1002
1087
  }
1003
1088
  return { matched: true, positions };
1004
1089
  }
1090
+ function frameEvidenceForSelector(viewport, selector) {
1091
+ const container = viewport.frames && viewport.frames[selector || ""];
1092
+ if (!container || typeof container !== "object" || Array.isArray(container)) return [];
1093
+ const frames = Array.isArray(container.frames) ? container.frames : [];
1094
+ return frames.filter((frame) => frame && typeof frame === "object" && !Array.isArray(frame));
1095
+ }
1096
+ function frameTextSample(frame) {
1097
+ return [
1098
+ frame && frame.title,
1099
+ frame && frame.text_sample,
1100
+ frame && frame.body_text_sample,
1101
+ frame && frame.body_text,
1102
+ frame && frame.text,
1103
+ ].map((part) => String(part || "")).filter(Boolean).join(" ");
1104
+ }
1005
1105
  function summarizeRouteInventory(viewport, inventory) {
1006
1106
  const directRoutes = Array.isArray(inventory.direct_routes) ? inventory.direct_routes : [];
1007
1107
  const clickthroughs = Array.isArray(inventory.clickthroughs) ? inventory.clickthroughs : [];
@@ -1248,6 +1348,62 @@ function assessProfile(profile, evidence) {
1248
1348
  });
1249
1349
  continue;
1250
1350
  }
1351
+ if (check.type === "frame_text_visible") {
1352
+ const selector = check.selector || "";
1353
+ const results = viewports.map((viewport) => {
1354
+ const frames = frameEvidenceForSelector(viewport, selector);
1355
+ const matches = frames
1356
+ .map((frame, index) => ({ index, frame, matched: textMatches(frameTextSample(frame), check) }))
1357
+ .filter((item) => item.matched);
1358
+ return {
1359
+ viewport: viewport.name,
1360
+ frame_count: frames.length,
1361
+ matched_count: matches.length,
1362
+ matched: matches.length > 0,
1363
+ urls: frames.map((frame) => String(frame.url || "")).filter(Boolean).slice(0, 10),
1364
+ samples: matches.map((item) => frameTextSample(item.frame).replace(/\s+/g, " ").trim().slice(0, 240)).slice(0, 3),
1365
+ };
1366
+ });
1367
+ const failed = results.filter((result) => !result.matched).length;
1368
+ checks.push({
1369
+ type: check.type,
1370
+ label: check.label || check.type,
1371
+ status: failed ? "failed" : "passed",
1372
+ evidence: { selector, text: check.text || null, pattern: check.pattern || null, viewports: results },
1373
+ message: failed ? "Frame selector " + selector + " did not contain expected text in " + failed + " viewport(s)." : undefined,
1374
+ });
1375
+ continue;
1376
+ }
1377
+ if (check.type === "frame_no_horizontal_overflow") {
1378
+ const selector = check.selector || "";
1379
+ const maxOverflow = check.max_overflow_px == null ? 4 : check.max_overflow_px;
1380
+ const results = viewports.map((viewport) => {
1381
+ const frames = frameEvidenceForSelector(viewport, selector);
1382
+ const frameOverflows = frames.map((frame, index) => ({
1383
+ index,
1384
+ url: String(frame.url || ""),
1385
+ overflow_px: horizontalBoundsOverflowPx(frame),
1386
+ offender_count: boundsOffendersForEvidence(frame).length,
1387
+ }));
1388
+ const maxFrameOverflow = frameOverflows.reduce((max, frame) => Math.max(max, frame.overflow_px), 0);
1389
+ return {
1390
+ viewport: viewport.name,
1391
+ frame_count: frames.length,
1392
+ max_overflow_px: roundPixels(maxFrameOverflow),
1393
+ failed_frame_count: frameOverflows.filter((frame) => frame.overflow_px > maxOverflow).length,
1394
+ frames: frameOverflows,
1395
+ };
1396
+ });
1397
+ const failed = results.filter((result) => result.frame_count < 1 || result.failed_frame_count > 0).length;
1398
+ checks.push({
1399
+ type: check.type,
1400
+ label: check.label || check.type,
1401
+ status: failed ? "failed" : "passed",
1402
+ evidence: { selector, max_overflow_px: maxOverflow, viewports: results },
1403
+ message: failed ? "Frame selector " + selector + " overflow exceeded " + maxOverflow + "px or was missing in " + failed + " viewport(s)." : undefined,
1404
+ });
1405
+ continue;
1406
+ }
1251
1407
  if (check.type === "text_visible" || check.type === "text_absent") {
1252
1408
  const key = check.pattern ? "pattern:" + check.pattern + "/" + (check.flags || "") : "text:" + (check.text || "");
1253
1409
  const expectedVisible = check.type === "text_visible";
@@ -1645,6 +1801,108 @@ async function selectorTextSequence(selector) {
1645
1801
  };
1646
1802
  }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
1647
1803
  }
1804
+ async function frameEvidence(selector) {
1805
+ const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
1806
+ let handles = [];
1807
+ try {
1808
+ const locator = page.locator(selector);
1809
+ result.count = await locator.count();
1810
+ handles = await locator.elementHandles();
1811
+ } catch (error) {
1812
+ result.errors.push(String(error && error.message ? error.message : error).slice(0, 500));
1813
+ return result;
1814
+ }
1815
+ for (let index = 0; index < handles.length; index += 1) {
1816
+ const handle = handles[index];
1817
+ try {
1818
+ const frame = typeof handle.contentFrame === "function" ? await handle.contentFrame() : null;
1819
+ if (!frame) {
1820
+ result.frames.push({ index, attached: false, error: "content_frame_unavailable" });
1821
+ continue;
1822
+ }
1823
+ const snapshot = await frame.evaluate(() => {
1824
+ const body = document.body;
1825
+ const documentElement = document.documentElement;
1826
+ const text = (body ? body.innerText : "").replace(/\s+/g, " ").trim();
1827
+ const clientWidth = documentElement ? documentElement.clientWidth : window.innerWidth;
1828
+ const clientHeight = documentElement ? documentElement.clientHeight : window.innerHeight;
1829
+ const scrollWidth = documentElement ? documentElement.scrollWidth : 0;
1830
+ const viewportWidth = clientWidth || window.innerWidth;
1831
+ const overflowOffenders = [];
1832
+ function isContainedByHorizontalScroller(element) {
1833
+ let current = element.parentElement;
1834
+ while (current && current !== body && current !== documentElement) {
1835
+ const style = window.getComputedStyle(current);
1836
+ const overflowX = style.overflowX || style.overflow || "";
1837
+ if ((overflowX === "auto" || overflowX === "scroll") && current.scrollWidth > current.clientWidth + 1) {
1838
+ const currentRect = current.getBoundingClientRect();
1839
+ const contained = currentRect.left >= -0.5 && currentRect.right <= viewportWidth + 0.5;
1840
+ if (contained) return true;
1841
+ }
1842
+ current = current.parentElement;
1843
+ }
1844
+ return false;
1845
+ }
1846
+ for (const element of Array.from(body ? body.querySelectorAll("*") : [])) {
1847
+ const rect = element.getBoundingClientRect();
1848
+ if (!rect || rect.width < 1 || rect.height < 1) continue;
1849
+ const style = window.getComputedStyle(element);
1850
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
1851
+ const leftOverflow = Math.max(0, -rect.left);
1852
+ const rightOverflow = Math.max(0, rect.right - viewportWidth);
1853
+ const overflow = Math.max(leftOverflow, rightOverflow);
1854
+ if (overflow <= 0.5) continue;
1855
+ if (isContainedByHorizontalScroller(element)) continue;
1856
+ const tag = element.tagName ? element.tagName.toLowerCase() : "element";
1857
+ const id = element.id ? "#" + element.id : "";
1858
+ const className = typeof element.className === "string"
1859
+ ? element.className.trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".")
1860
+ : "";
1861
+ overflowOffenders.push({
1862
+ selector: tag + id + (className ? "." + className : ""),
1863
+ tag,
1864
+ text: (element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120),
1865
+ overflow,
1866
+ left_overflow_px: leftOverflow,
1867
+ right_overflow_px: rightOverflow,
1868
+ viewport_width: viewportWidth,
1869
+ rect: {
1870
+ left: rect.left,
1871
+ right: rect.right,
1872
+ width: rect.width,
1873
+ },
1874
+ });
1875
+ }
1876
+ overflowOffenders.sort((a, b) => b.overflow - a.overflow);
1877
+ const boundsOverflowPx = Math.max(
1878
+ 0,
1879
+ scrollWidth - (clientWidth || viewportWidth),
1880
+ ...overflowOffenders.map((offender) => offender.overflow),
1881
+ );
1882
+ return {
1883
+ url: location.href,
1884
+ title: document.title,
1885
+ text_length: text.length,
1886
+ text_sample: text.slice(0, 8000),
1887
+ body_text_sample: text.slice(0, 8000),
1888
+ viewport_width: viewportWidth,
1889
+ viewport_height: clientHeight || window.innerHeight,
1890
+ scroll_width: scrollWidth,
1891
+ client_width: clientWidth,
1892
+ overflow_px: Math.max(0, scrollWidth - (clientWidth || viewportWidth)),
1893
+ bounds_overflow_px: Math.round(boundsOverflowPx * 100) / 100,
1894
+ overflow_offender_count: overflowOffenders.length,
1895
+ overflow_offenders: overflowOffenders.slice(0, 10),
1896
+ };
1897
+ });
1898
+ result.frames.push({ index, attached: true, ...snapshot });
1899
+ } catch (error) {
1900
+ result.frames.push({ index, attached: false, error: String(error && error.message ? error.message : error).slice(0, 1000) });
1901
+ }
1902
+ }
1903
+ result.frame_count = result.frames.filter((frame) => frame && frame.attached !== false && !frame.error).length;
1904
+ return result;
1905
+ }
1648
1906
  function inventoryAppPathFromUrl(urlLike) {
1649
1907
  const url = new URL(String(urlLike), targetUrl);
1650
1908
  const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
@@ -2049,6 +2307,7 @@ async function captureViewport(viewport) {
2049
2307
  evaluation_error: String(error && error.message ? error.message : error).slice(0, 1000),
2050
2308
  }));
2051
2309
  const selectors = {};
2310
+ const frames = {};
2052
2311
  const text_sequences = {};
2053
2312
  const text_matches = {};
2054
2313
  for (const check of profile.checks || []) {
@@ -2062,6 +2321,10 @@ async function captureViewport(viewport) {
2062
2321
  if ((check.type === "text_visible" || check.type === "text_absent") && (check.text || check.pattern)) {
2063
2322
  text_matches[textKey(check)] = textMatches(dom.body_text || dom.body_text_sample || "", check);
2064
2323
  }
2324
+ if ((check.type === "frame_text_visible" || check.type === "frame_no_horizontal_overflow") && check.selector) {
2325
+ selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
2326
+ frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
2327
+ }
2065
2328
  }
2066
2329
  const screenshotLabel = profileSlug + "-" + viewport.name;
2067
2330
  try {
@@ -2122,6 +2385,7 @@ async function captureViewport(viewport) {
2122
2385
  bounds_overflow_px: dom.bounds_overflow_px,
2123
2386
  overflow_offenders: dom.overflow_offenders || [],
2124
2387
  selectors,
2388
+ frames,
2125
2389
  text_sequences,
2126
2390
  text_matches,
2127
2391
  route_inventory: routeInventory,
@@ -2157,6 +2421,20 @@ function buildProfileEvidence(currentViewports) {
2157
2421
  overflow_px: currentViewports.map((viewport) => viewport.overflow_px),
2158
2422
  bounds_overflow_px: currentViewports.map((viewport) => viewport.bounds_overflow_px),
2159
2423
  overflow_offender_counts: currentViewports.map((viewport) => (viewport.overflow_offenders || []).length),
2424
+ frames: currentViewports
2425
+ .filter((viewport) => viewport.frames)
2426
+ .map((viewport) => ({
2427
+ viewport: viewport.name,
2428
+ selectors: Object.entries(viewport.frames || {}).map(([selector, frameSet]) => ({
2429
+ selector,
2430
+ count: frameSet && frameSet.count,
2431
+ frame_count: frameSet && frameSet.frame_count,
2432
+ max_bounds_overflow_px: Math.max(
2433
+ 0,
2434
+ ...((frameSet && Array.isArray(frameSet.frames) ? frameSet.frames : [])).map((frame) => horizontalBoundsOverflowPx(frame)),
2435
+ ),
2436
+ })),
2437
+ })),
2160
2438
  route_inventory: currentViewports
2161
2439
  .filter((viewport) => viewport.route_inventory)
2162
2440
  .map((viewport) => ({