@riddledc/riddle-proof 0.7.36 → 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:
@@ -247,7 +270,9 @@ Set `run_direct_routes: false`, `run_clickthroughs: false`,
247
270
  `save_route_screenshots: true` when a profile needs a narrower or more
248
271
  artifact-heavy audit. `require_unique_routes: false` is useful when a navigation
249
272
  surface intentionally links to the same route from multiple cards or anchors,
250
- while still proving the unique expected route set.
273
+ while still proving the unique expected route set. When `run_all_viewports` and
274
+ `save_route_screenshots` are both enabled, route screenshot artifact labels
275
+ include the viewport name so desktop and mobile route artifacts remain distinct.
251
276
 
252
277
  The result uses `riddle-proof.profile-result.v1` and separates product failures
253
278
  from weak proof and environment blockers:
@@ -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);
@@ -1666,6 +1924,13 @@ function inventoryRouteUrl(expectedPath) {
1666
1924
  function inventorySlugFromPath(path) {
1667
1925
  return String(path || "route").split("/").filter(Boolean).pop()?.replace(/[^a-z0-9]+/gi, "-").toLowerCase() || "route";
1668
1926
  }
1927
+ function inventorySlugFromViewport(viewport) {
1928
+ return String(viewport && viewport.name || "viewport").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "viewport";
1929
+ }
1930
+ function inventoryScreenshotLabel(check, viewport, phase, path) {
1931
+ const viewportPart = check.run_all_viewports ? "-" + inventorySlugFromViewport(viewport) : "";
1932
+ return profileSlug + viewportPart + "-" + phase + "-" + inventorySlugFromPath(path);
1933
+ }
1669
1934
  function inventoryCheckTimeout(check) {
1670
1935
  const timeout = Number(check && check.timeout_ms);
1671
1936
  return Number.isFinite(timeout) && timeout > 0 ? timeout : 45000;
@@ -1874,7 +2139,7 @@ async function collectRouteInventory(check, viewport) {
1874
2139
  const snapshot = await collectInventoryRouteSnapshot(expectedRoute, "direct", check, error);
1875
2140
  directRoutes.push(snapshot);
1876
2141
  if (check.save_route_screenshots) {
1877
- await saveScreenshot(profileSlug + "-direct-" + inventorySlugFromPath(expectedRoute.path)).catch(() => {});
2142
+ await saveScreenshot(inventoryScreenshotLabel(check, viewport, "direct", expectedRoute.path)).catch(() => {});
1878
2143
  }
1879
2144
  if (error) failures.push({ code: "direct_route_unhealthy", name: expectedRoute.name || null, path: expectedRoute.path, error });
1880
2145
  else if (snapshot.actual_app_path !== normalizeRoutePath(expectedRoute.path)) failures.push({ code: "direct_route_wrong_path", name: expectedRoute.name || null, path: expectedRoute.path, actual_app_path: snapshot.actual_app_path });
@@ -1908,7 +2173,7 @@ async function collectRouteInventory(check, viewport) {
1908
2173
  result.snapshot = await collectInventoryRouteSnapshot(expectedRoute, "clickthrough", check, result.error || "");
1909
2174
  clickthroughs.push(result);
1910
2175
  if (check.save_route_screenshots) {
1911
- await saveScreenshot(profileSlug + "-click-" + inventorySlugFromPath(expectedRoute.path)).catch(() => {});
2176
+ await saveScreenshot(inventoryScreenshotLabel(check, viewport, "click", expectedRoute.path)).catch(() => {});
1912
2177
  }
1913
2178
  if (result.error && result.error !== "source_link_not_found") failures.push({ code: "source_link_clickthrough_unhealthy", name: expectedRoute.name || null, path: expectedRoute.path, error: result.error });
1914
2179
  else if (result.snapshot.actual_app_path !== normalizeRoutePath(expectedRoute.path)) failures.push({ code: "source_link_clickthrough_wrong_path", name: expectedRoute.name || null, path: expectedRoute.path, actual_app_path: result.snapshot.actual_app_path });
@@ -2042,6 +2307,7 @@ async function captureViewport(viewport) {
2042
2307
  evaluation_error: String(error && error.message ? error.message : error).slice(0, 1000),
2043
2308
  }));
2044
2309
  const selectors = {};
2310
+ const frames = {};
2045
2311
  const text_sequences = {};
2046
2312
  const text_matches = {};
2047
2313
  for (const check of profile.checks || []) {
@@ -2055,6 +2321,10 @@ async function captureViewport(viewport) {
2055
2321
  if ((check.type === "text_visible" || check.type === "text_absent") && (check.text || check.pattern)) {
2056
2322
  text_matches[textKey(check)] = textMatches(dom.body_text || dom.body_text_sample || "", check);
2057
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
+ }
2058
2328
  }
2059
2329
  const screenshotLabel = profileSlug + "-" + viewport.name;
2060
2330
  try {
@@ -2115,6 +2385,7 @@ async function captureViewport(viewport) {
2115
2385
  bounds_overflow_px: dom.bounds_overflow_px,
2116
2386
  overflow_offenders: dom.overflow_offenders || [],
2117
2387
  selectors,
2388
+ frames,
2118
2389
  text_sequences,
2119
2390
  text_matches,
2120
2391
  route_inventory: routeInventory,
@@ -2150,6 +2421,20 @@ function buildProfileEvidence(currentViewports) {
2150
2421
  overflow_px: currentViewports.map((viewport) => viewport.overflow_px),
2151
2422
  bounds_overflow_px: currentViewports.map((viewport) => viewport.bounds_overflow_px),
2152
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
+ })),
2153
2438
  route_inventory: currentViewports
2154
2439
  .filter((viewport) => viewport.route_inventory)
2155
2440
  .map((viewport) => ({