@riddledc/riddle-proof 0.7.37 → 0.7.39

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
@@ -202,6 +202,25 @@ clears `local`, `session`, or `both` browser storage scopes, defaults to
202
202
  profile carries its own hosted Riddle worker budget; an explicit CLI `--timeout`
203
203
  still overrides the profile value for one-off runs.
204
204
 
205
+ Use `allowed_console_patterns` / `allowed_console_texts` on
206
+ `no_fatal_console_errors` when a negative-path profile intentionally triggers a
207
+ known browser console error, such as a mocked `503` that the app recovers from:
208
+
209
+ ```json
210
+ {
211
+ "type": "no_fatal_console_errors",
212
+ "allowed_console_patterns": [
213
+ "Failed to load resource: the server responded with a status of 503",
214
+ "Build failed: Error: Synthetic build outage"
215
+ ]
216
+ }
217
+ ```
218
+
219
+ Allowed console events and page errors are still counted in check evidence, but
220
+ only unallowed `error` / `assert` console events and page errors fail the check.
221
+ Use `allowed_page_error_patterns`, `allowed_console_texts`, or
222
+ `allowed_page_error_texts` for narrower matching when needed.
223
+
205
224
  Use `selector_text_order` when a table, list, or card group must show visible
206
225
  items in a specific order after setup actions such as sorting or filtering:
207
226
 
@@ -217,6 +236,29 @@ The check records the visible text sequence for the selector and passes when
217
236
  the expected texts appear in that order as a subsequence. This is less brittle
218
237
  than matching one large body-text regex when only row or card order matters.
219
238
 
239
+ Use `frame_text_visible` and `frame_no_horizontal_overflow` for embedded app,
240
+ game, or preview surfaces that render inside iframes:
241
+
242
+ ```json
243
+ [
244
+ {
245
+ "type": "frame_text_visible",
246
+ "selector": ".game-player-root iframe",
247
+ "text": "Start Game"
248
+ },
249
+ {
250
+ "type": "frame_no_horizontal_overflow",
251
+ "selector": ".game-player-root iframe",
252
+ "max_overflow_px": 1
253
+ }
254
+ ]
255
+ ```
256
+
257
+ Frame checks capture each matching iframe's URL, title, compact text sample,
258
+ scroll width, client width, measured horizontal overflow, and top visible
259
+ overflow offenders. This keeps embedded-player audits in profile mode instead
260
+ of requiring bespoke iframe inspection scripts.
261
+
220
262
  Use the `route_inventory` check for source-page route coverage audits where a
221
263
  navigation surface must expose a known set of routes and each route must load
222
264
  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",
@@ -303,6 +305,13 @@ function normalizeExpectedTexts(value, index) {
303
305
  if (!texts.length) throw new Error(`checks[${index}] selector_text_order expected_texts must contain non-empty strings.`);
304
306
  return texts;
305
307
  }
308
+ function normalizeStringList(value, label) {
309
+ if (value === void 0) return void 0;
310
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
311
+ const values = value.map((item) => String(item).replace(/\s+/g, " ").trim()).filter(Boolean);
312
+ if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
313
+ return values;
314
+ }
306
315
  function normalizeCheck(input, index) {
307
316
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
308
317
  const type = stringValue(input.type);
@@ -313,6 +322,12 @@ function normalizeCheck(input, index) {
313
322
  if ((type === "selector_visible" || type === "selector_count_at_least") && !stringValue(input.selector)) {
314
323
  throw new Error(`checks[${index}] ${type} requires selector.`);
315
324
  }
325
+ if ((type === "frame_text_visible" || type === "frame_no_horizontal_overflow") && !stringValue(input.selector)) {
326
+ throw new Error(`checks[${index}] ${type} requires selector.`);
327
+ }
328
+ if (type === "frame_text_visible" && !stringValue(input.text) && !stringValue(input.pattern)) {
329
+ throw new Error(`checks[${index}] frame_text_visible requires text or pattern.`);
330
+ }
316
331
  if ((type === "text_visible" || type === "text_absent") && !stringValue(input.text) && !stringValue(input.pattern)) {
317
332
  throw new Error(`checks[${index}] ${type} requires text or pattern.`);
318
333
  }
@@ -345,6 +360,10 @@ function normalizeCheck(input, index) {
345
360
  text: stringValue(input.text),
346
361
  pattern: stringValue(input.pattern),
347
362
  flags: stringValue(input.flags),
363
+ allowed_console_texts: normalizeStringList(input.allowed_console_texts ?? input.allowedConsoleTexts ?? input.allow_console_texts ?? input.allowConsoleTexts, `checks[${index}] allowed_console_texts`),
364
+ allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
365
+ allowed_page_error_texts: normalizeStringList(input.allowed_page_error_texts ?? input.allowedPageErrorTexts ?? input.allow_page_error_texts ?? input.allowPageErrorTexts, `checks[${index}] allowed_page_error_texts`),
366
+ allowed_page_error_patterns: normalizeStringList(input.allowed_page_error_patterns ?? input.allowedPageErrorPatterns ?? input.allow_page_error_patterns ?? input.allowPageErrorPatterns, `checks[${index}] allowed_page_error_patterns`),
348
367
  min_count: numberValue(input.min_count),
349
368
  max_overflow_px: numberValue(input.max_overflow_px),
350
369
  timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
@@ -463,6 +482,22 @@ function textOrderMatch(texts, expectedTexts) {
463
482
  }
464
483
  return { matched: true, positions };
465
484
  }
485
+ function frameEvidenceForSelector(viewport, selector) {
486
+ const container = viewport.frames?.[selector];
487
+ if (!isRecord(container)) return [];
488
+ const frames = Array.isArray(container.frames) ? container.frames : [];
489
+ return frames.filter((frame) => isRecord(frame));
490
+ }
491
+ function frameTextSample(frame) {
492
+ const parts = [
493
+ frame.title,
494
+ frame.text_sample,
495
+ frame.body_text_sample,
496
+ frame.body_text,
497
+ frame.text
498
+ ];
499
+ return parts.map((part) => String(part || "")).filter(Boolean).join(" ");
500
+ }
466
501
  function summarizeRouteInventory(viewport, inventory) {
467
502
  const directRoutes = Array.isArray(inventory.direct_routes) ? inventory.direct_routes : [];
468
503
  const clickthroughs = Array.isArray(inventory.clickthroughs) ? inventory.clickthroughs : [];
@@ -492,6 +527,18 @@ function matchText(sample, check) {
492
527
  }
493
528
  return sample.includes(check.text || "");
494
529
  }
530
+ function matchesAllowedMessage(message, texts, patterns) {
531
+ const sample = String(message || "");
532
+ if (texts?.some((text) => sample.includes(text))) return true;
533
+ for (const pattern of patterns || []) {
534
+ try {
535
+ if (new RegExp(pattern).test(sample)) return true;
536
+ } catch {
537
+ continue;
538
+ }
539
+ }
540
+ return false;
541
+ }
495
542
  function normalizeRoutePath(path) {
496
543
  const value = path || "/";
497
544
  if (value === "/") return "/";
@@ -627,6 +674,67 @@ function assessCheckFromEvidence(check, evidence) {
627
674
  message: failed ? `Selector ${key} text order failed in ${failed} viewport(s).` : void 0
628
675
  };
629
676
  }
677
+ if (check.type === "frame_text_visible") {
678
+ const key = selectorKey(check);
679
+ const results = viewports.map((viewport) => {
680
+ const frames = frameEvidenceForSelector(viewport, key);
681
+ const matches = frames.map((frame, index) => ({ index, frame, matched: matchText(frameTextSample(frame), check) })).filter((item) => item.matched);
682
+ return {
683
+ viewport: viewport.name,
684
+ frame_count: frames.length,
685
+ matched_count: matches.length,
686
+ matched: matches.length > 0,
687
+ urls: frames.map((frame) => String(frame.url || "")).filter(Boolean).slice(0, 10),
688
+ samples: matches.map((item) => frameTextSample(item.frame).replace(/\s+/g, " ").trim().slice(0, 240)).slice(0, 3)
689
+ };
690
+ });
691
+ const failed = results.filter((result) => !result.matched).length;
692
+ return {
693
+ type: check.type,
694
+ label: checkLabel(check),
695
+ status: failed ? "failed" : "passed",
696
+ evidence: {
697
+ selector: key,
698
+ text: check.text || null,
699
+ pattern: check.pattern || null,
700
+ viewports: results.map((result) => toJsonValue(result))
701
+ },
702
+ message: failed ? `Frame selector ${key} did not contain expected text in ${failed} viewport(s).` : void 0
703
+ };
704
+ }
705
+ if (check.type === "frame_no_horizontal_overflow") {
706
+ const key = selectorKey(check);
707
+ const maxOverflow = check.max_overflow_px ?? 4;
708
+ const results = viewports.map((viewport) => {
709
+ const frames = frameEvidenceForSelector(viewport, key);
710
+ const frameOverflows = frames.map((frame, index) => ({
711
+ index,
712
+ url: String(frame.url || ""),
713
+ overflow_px: horizontalBoundsOverflowPx(frame),
714
+ offender_count: boundsOffendersForEvidence(frame).length
715
+ }));
716
+ const maxFrameOverflow = frameOverflows.reduce((max, frame) => Math.max(max, frame.overflow_px), 0);
717
+ return {
718
+ viewport: viewport.name,
719
+ frame_count: frames.length,
720
+ max_overflow_px: roundPixels(maxFrameOverflow),
721
+ failed_frame_count: frameOverflows.filter((frame) => frame.overflow_px > maxOverflow).length,
722
+ frames: frameOverflows.map((frame) => toJsonValue(frame))
723
+ };
724
+ });
725
+ const failed = results.filter((result) => result.frame_count < 1 || result.failed_frame_count > 0).length;
726
+ return {
727
+ type: check.type,
728
+ label: checkLabel(check),
729
+ status: failed ? "failed" : "passed",
730
+ evidence: {
731
+ selector: key,
732
+ max_overflow_px: maxOverflow,
733
+ viewports: results.map((result) => toJsonValue(result))
734
+ },
735
+ message: failed ? `Frame selector ${key} overflow exceeded ${maxOverflow}px or was missing in ${failed} viewport(s).` : void 0
736
+ };
737
+ }
630
738
  if (check.type === "text_visible" || check.type === "text_absent") {
631
739
  const key = textKey(check);
632
740
  const expectedVisible = check.type === "text_visible";
@@ -717,14 +825,28 @@ function assessCheckFromEvidence(check, evidence) {
717
825
  };
718
826
  }
719
827
  if (check.type === "no_fatal_console_errors") {
720
- const fatalCount = (evidence.console?.fatal_count || 0) + (evidence.page_errors?.length || 0);
828
+ const fatalConsoleEvents = (evidence.console?.events || []).filter((event) => event.type === "error" || event.type === "assert");
829
+ const allowedConsoleEvents = fatalConsoleEvents.filter((event) => matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
830
+ const unallowedConsoleEvents = fatalConsoleEvents.filter((event) => !matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
831
+ const pageErrors = evidence.page_errors || [];
832
+ const allowedPageErrors = pageErrors.filter((error) => matchesAllowedMessage(error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
833
+ const unallowedPageErrors = pageErrors.filter((error) => !matchesAllowedMessage(error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
834
+ const fatalCount = unallowedConsoleEvents.length + unallowedPageErrors.length;
721
835
  return {
722
836
  type: check.type,
723
837
  label: checkLabel(check),
724
838
  status: fatalCount ? "failed" : "passed",
725
839
  evidence: {
726
- console_fatal_count: evidence.console?.fatal_count || 0,
727
- page_error_count: evidence.page_errors?.length || 0
840
+ console_fatal_count: unallowedConsoleEvents.length,
841
+ page_error_count: unallowedPageErrors.length,
842
+ total_console_fatal_count: fatalConsoleEvents.length,
843
+ total_page_error_count: pageErrors.length,
844
+ allowed_console_fatal_count: allowedConsoleEvents.length,
845
+ allowed_page_error_count: allowedPageErrors.length,
846
+ allowed_console_texts: check.allowed_console_texts || [],
847
+ allowed_console_patterns: check.allowed_console_patterns || [],
848
+ allowed_page_error_texts: check.allowed_page_error_texts || [],
849
+ allowed_page_error_patterns: check.allowed_page_error_patterns || []
728
850
  },
729
851
  message: fatalCount ? `${fatalCount} fatal browser error(s) were captured.` : void 0
730
852
  };
@@ -980,6 +1102,16 @@ function textMatches(sample, check) {
980
1102
  }
981
1103
  return String(sample || "").includes(check.text || "");
982
1104
  }
1105
+ function matchesAllowedMessage(message, texts, patterns) {
1106
+ const sample = String(message || "");
1107
+ if ((texts || []).some((text) => sample.includes(text))) return true;
1108
+ for (const pattern of patterns || []) {
1109
+ try {
1110
+ if (new RegExp(pattern).test(sample)) return true;
1111
+ } catch {}
1112
+ }
1113
+ return false;
1114
+ }
983
1115
  function textSequenceForCheck(viewport, check) {
984
1116
  const key = check.selector || "";
985
1117
  const sequence = viewport.text_sequences && viewport.text_sequences[key];
@@ -1002,6 +1134,21 @@ function textOrderMatch(texts, expectedTexts) {
1002
1134
  }
1003
1135
  return { matched: true, positions };
1004
1136
  }
1137
+ function frameEvidenceForSelector(viewport, selector) {
1138
+ const container = viewport.frames && viewport.frames[selector || ""];
1139
+ if (!container || typeof container !== "object" || Array.isArray(container)) return [];
1140
+ const frames = Array.isArray(container.frames) ? container.frames : [];
1141
+ return frames.filter((frame) => frame && typeof frame === "object" && !Array.isArray(frame));
1142
+ }
1143
+ function frameTextSample(frame) {
1144
+ return [
1145
+ frame && frame.title,
1146
+ frame && frame.text_sample,
1147
+ frame && frame.body_text_sample,
1148
+ frame && frame.body_text,
1149
+ frame && frame.text,
1150
+ ].map((part) => String(part || "")).filter(Boolean).join(" ");
1151
+ }
1005
1152
  function summarizeRouteInventory(viewport, inventory) {
1006
1153
  const directRoutes = Array.isArray(inventory.direct_routes) ? inventory.direct_routes : [];
1007
1154
  const clickthroughs = Array.isArray(inventory.clickthroughs) ? inventory.clickthroughs : [];
@@ -1248,6 +1395,62 @@ function assessProfile(profile, evidence) {
1248
1395
  });
1249
1396
  continue;
1250
1397
  }
1398
+ if (check.type === "frame_text_visible") {
1399
+ const selector = check.selector || "";
1400
+ const results = viewports.map((viewport) => {
1401
+ const frames = frameEvidenceForSelector(viewport, selector);
1402
+ const matches = frames
1403
+ .map((frame, index) => ({ index, frame, matched: textMatches(frameTextSample(frame), check) }))
1404
+ .filter((item) => item.matched);
1405
+ return {
1406
+ viewport: viewport.name,
1407
+ frame_count: frames.length,
1408
+ matched_count: matches.length,
1409
+ matched: matches.length > 0,
1410
+ urls: frames.map((frame) => String(frame.url || "")).filter(Boolean).slice(0, 10),
1411
+ samples: matches.map((item) => frameTextSample(item.frame).replace(/\s+/g, " ").trim().slice(0, 240)).slice(0, 3),
1412
+ };
1413
+ });
1414
+ const failed = results.filter((result) => !result.matched).length;
1415
+ checks.push({
1416
+ type: check.type,
1417
+ label: check.label || check.type,
1418
+ status: failed ? "failed" : "passed",
1419
+ evidence: { selector, text: check.text || null, pattern: check.pattern || null, viewports: results },
1420
+ message: failed ? "Frame selector " + selector + " did not contain expected text in " + failed + " viewport(s)." : undefined,
1421
+ });
1422
+ continue;
1423
+ }
1424
+ if (check.type === "frame_no_horizontal_overflow") {
1425
+ const selector = check.selector || "";
1426
+ const maxOverflow = check.max_overflow_px == null ? 4 : check.max_overflow_px;
1427
+ const results = viewports.map((viewport) => {
1428
+ const frames = frameEvidenceForSelector(viewport, selector);
1429
+ const frameOverflows = frames.map((frame, index) => ({
1430
+ index,
1431
+ url: String(frame.url || ""),
1432
+ overflow_px: horizontalBoundsOverflowPx(frame),
1433
+ offender_count: boundsOffendersForEvidence(frame).length,
1434
+ }));
1435
+ const maxFrameOverflow = frameOverflows.reduce((max, frame) => Math.max(max, frame.overflow_px), 0);
1436
+ return {
1437
+ viewport: viewport.name,
1438
+ frame_count: frames.length,
1439
+ max_overflow_px: roundPixels(maxFrameOverflow),
1440
+ failed_frame_count: frameOverflows.filter((frame) => frame.overflow_px > maxOverflow).length,
1441
+ frames: frameOverflows,
1442
+ };
1443
+ });
1444
+ const failed = results.filter((result) => result.frame_count < 1 || result.failed_frame_count > 0).length;
1445
+ checks.push({
1446
+ type: check.type,
1447
+ label: check.label || check.type,
1448
+ status: failed ? "failed" : "passed",
1449
+ evidence: { selector, max_overflow_px: maxOverflow, viewports: results },
1450
+ message: failed ? "Frame selector " + selector + " overflow exceeded " + maxOverflow + "px or was missing in " + failed + " viewport(s)." : undefined,
1451
+ });
1452
+ continue;
1453
+ }
1251
1454
  if (check.type === "text_visible" || check.type === "text_absent") {
1252
1455
  const key = check.pattern ? "pattern:" + check.pattern + "/" + (check.flags || "") : "text:" + (check.text || "");
1253
1456
  const expectedVisible = check.type === "text_visible";
@@ -1343,12 +1546,29 @@ function assessProfile(profile, evidence) {
1343
1546
  continue;
1344
1547
  }
1345
1548
  if (check.type === "no_fatal_console_errors") {
1346
- const fatalCount = ((evidence.console && evidence.console.fatal_count) || 0) + ((evidence.page_errors || []).length);
1549
+ const fatalConsoleEvents = ((evidence.console && evidence.console.events) || []).filter((event) => event && (event.type === "error" || event.type === "assert"));
1550
+ const allowedConsoleEvents = fatalConsoleEvents.filter((event) => matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
1551
+ const unallowedConsoleEvents = fatalConsoleEvents.filter((event) => !matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
1552
+ const pageErrors = evidence.page_errors || [];
1553
+ const allowedPageErrors = pageErrors.filter((error) => matchesAllowedMessage(error && error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
1554
+ const unallowedPageErrors = pageErrors.filter((error) => !matchesAllowedMessage(error && error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
1555
+ const fatalCount = unallowedConsoleEvents.length + unallowedPageErrors.length;
1347
1556
  checks.push({
1348
1557
  type: check.type,
1349
1558
  label: check.label || check.type,
1350
1559
  status: fatalCount ? "failed" : "passed",
1351
- evidence: { console_fatal_count: (evidence.console && evidence.console.fatal_count) || 0, page_error_count: (evidence.page_errors || []).length },
1560
+ evidence: {
1561
+ console_fatal_count: unallowedConsoleEvents.length,
1562
+ page_error_count: unallowedPageErrors.length,
1563
+ total_console_fatal_count: fatalConsoleEvents.length,
1564
+ total_page_error_count: pageErrors.length,
1565
+ allowed_console_fatal_count: allowedConsoleEvents.length,
1566
+ allowed_page_error_count: allowedPageErrors.length,
1567
+ allowed_console_texts: check.allowed_console_texts || [],
1568
+ allowed_console_patterns: check.allowed_console_patterns || [],
1569
+ allowed_page_error_texts: check.allowed_page_error_texts || [],
1570
+ allowed_page_error_patterns: check.allowed_page_error_patterns || [],
1571
+ },
1352
1572
  message: fatalCount ? String(fatalCount) + " fatal browser error(s) were captured." : undefined,
1353
1573
  });
1354
1574
  continue;
@@ -1645,6 +1865,108 @@ async function selectorTextSequence(selector) {
1645
1865
  };
1646
1866
  }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
1647
1867
  }
1868
+ async function frameEvidence(selector) {
1869
+ const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
1870
+ let handles = [];
1871
+ try {
1872
+ const locator = page.locator(selector);
1873
+ result.count = await locator.count();
1874
+ handles = await locator.elementHandles();
1875
+ } catch (error) {
1876
+ result.errors.push(String(error && error.message ? error.message : error).slice(0, 500));
1877
+ return result;
1878
+ }
1879
+ for (let index = 0; index < handles.length; index += 1) {
1880
+ const handle = handles[index];
1881
+ try {
1882
+ const frame = typeof handle.contentFrame === "function" ? await handle.contentFrame() : null;
1883
+ if (!frame) {
1884
+ result.frames.push({ index, attached: false, error: "content_frame_unavailable" });
1885
+ continue;
1886
+ }
1887
+ const snapshot = await frame.evaluate(() => {
1888
+ const body = document.body;
1889
+ const documentElement = document.documentElement;
1890
+ const text = (body ? body.innerText : "").replace(/\s+/g, " ").trim();
1891
+ const clientWidth = documentElement ? documentElement.clientWidth : window.innerWidth;
1892
+ const clientHeight = documentElement ? documentElement.clientHeight : window.innerHeight;
1893
+ const scrollWidth = documentElement ? documentElement.scrollWidth : 0;
1894
+ const viewportWidth = clientWidth || window.innerWidth;
1895
+ const overflowOffenders = [];
1896
+ function isContainedByHorizontalScroller(element) {
1897
+ let current = element.parentElement;
1898
+ while (current && current !== body && current !== documentElement) {
1899
+ const style = window.getComputedStyle(current);
1900
+ const overflowX = style.overflowX || style.overflow || "";
1901
+ if ((overflowX === "auto" || overflowX === "scroll") && current.scrollWidth > current.clientWidth + 1) {
1902
+ const currentRect = current.getBoundingClientRect();
1903
+ const contained = currentRect.left >= -0.5 && currentRect.right <= viewportWidth + 0.5;
1904
+ if (contained) return true;
1905
+ }
1906
+ current = current.parentElement;
1907
+ }
1908
+ return false;
1909
+ }
1910
+ for (const element of Array.from(body ? body.querySelectorAll("*") : [])) {
1911
+ const rect = element.getBoundingClientRect();
1912
+ if (!rect || rect.width < 1 || rect.height < 1) continue;
1913
+ const style = window.getComputedStyle(element);
1914
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
1915
+ const leftOverflow = Math.max(0, -rect.left);
1916
+ const rightOverflow = Math.max(0, rect.right - viewportWidth);
1917
+ const overflow = Math.max(leftOverflow, rightOverflow);
1918
+ if (overflow <= 0.5) continue;
1919
+ if (isContainedByHorizontalScroller(element)) continue;
1920
+ const tag = element.tagName ? element.tagName.toLowerCase() : "element";
1921
+ const id = element.id ? "#" + element.id : "";
1922
+ const className = typeof element.className === "string"
1923
+ ? element.className.trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".")
1924
+ : "";
1925
+ overflowOffenders.push({
1926
+ selector: tag + id + (className ? "." + className : ""),
1927
+ tag,
1928
+ text: (element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120),
1929
+ overflow,
1930
+ left_overflow_px: leftOverflow,
1931
+ right_overflow_px: rightOverflow,
1932
+ viewport_width: viewportWidth,
1933
+ rect: {
1934
+ left: rect.left,
1935
+ right: rect.right,
1936
+ width: rect.width,
1937
+ },
1938
+ });
1939
+ }
1940
+ overflowOffenders.sort((a, b) => b.overflow - a.overflow);
1941
+ const boundsOverflowPx = Math.max(
1942
+ 0,
1943
+ scrollWidth - (clientWidth || viewportWidth),
1944
+ ...overflowOffenders.map((offender) => offender.overflow),
1945
+ );
1946
+ return {
1947
+ url: location.href,
1948
+ title: document.title,
1949
+ text_length: text.length,
1950
+ text_sample: text.slice(0, 8000),
1951
+ body_text_sample: text.slice(0, 8000),
1952
+ viewport_width: viewportWidth,
1953
+ viewport_height: clientHeight || window.innerHeight,
1954
+ scroll_width: scrollWidth,
1955
+ client_width: clientWidth,
1956
+ overflow_px: Math.max(0, scrollWidth - (clientWidth || viewportWidth)),
1957
+ bounds_overflow_px: Math.round(boundsOverflowPx * 100) / 100,
1958
+ overflow_offender_count: overflowOffenders.length,
1959
+ overflow_offenders: overflowOffenders.slice(0, 10),
1960
+ };
1961
+ });
1962
+ result.frames.push({ index, attached: true, ...snapshot });
1963
+ } catch (error) {
1964
+ result.frames.push({ index, attached: false, error: String(error && error.message ? error.message : error).slice(0, 1000) });
1965
+ }
1966
+ }
1967
+ result.frame_count = result.frames.filter((frame) => frame && frame.attached !== false && !frame.error).length;
1968
+ return result;
1969
+ }
1648
1970
  function inventoryAppPathFromUrl(urlLike) {
1649
1971
  const url = new URL(String(urlLike), targetUrl);
1650
1972
  const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
@@ -2049,6 +2371,7 @@ async function captureViewport(viewport) {
2049
2371
  evaluation_error: String(error && error.message ? error.message : error).slice(0, 1000),
2050
2372
  }));
2051
2373
  const selectors = {};
2374
+ const frames = {};
2052
2375
  const text_sequences = {};
2053
2376
  const text_matches = {};
2054
2377
  for (const check of profile.checks || []) {
@@ -2062,6 +2385,10 @@ async function captureViewport(viewport) {
2062
2385
  if ((check.type === "text_visible" || check.type === "text_absent") && (check.text || check.pattern)) {
2063
2386
  text_matches[textKey(check)] = textMatches(dom.body_text || dom.body_text_sample || "", check);
2064
2387
  }
2388
+ if ((check.type === "frame_text_visible" || check.type === "frame_no_horizontal_overflow") && check.selector) {
2389
+ selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
2390
+ frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
2391
+ }
2065
2392
  }
2066
2393
  const screenshotLabel = profileSlug + "-" + viewport.name;
2067
2394
  try {
@@ -2122,6 +2449,7 @@ async function captureViewport(viewport) {
2122
2449
  bounds_overflow_px: dom.bounds_overflow_px,
2123
2450
  overflow_offenders: dom.overflow_offenders || [],
2124
2451
  selectors,
2452
+ frames,
2125
2453
  text_sequences,
2126
2454
  text_matches,
2127
2455
  route_inventory: routeInventory,
@@ -2157,6 +2485,20 @@ function buildProfileEvidence(currentViewports) {
2157
2485
  overflow_px: currentViewports.map((viewport) => viewport.overflow_px),
2158
2486
  bounds_overflow_px: currentViewports.map((viewport) => viewport.bounds_overflow_px),
2159
2487
  overflow_offender_counts: currentViewports.map((viewport) => (viewport.overflow_offenders || []).length),
2488
+ frames: currentViewports
2489
+ .filter((viewport) => viewport.frames)
2490
+ .map((viewport) => ({
2491
+ viewport: viewport.name,
2492
+ selectors: Object.entries(viewport.frames || {}).map(([selector, frameSet]) => ({
2493
+ selector,
2494
+ count: frameSet && frameSet.count,
2495
+ frame_count: frameSet && frameSet.frame_count,
2496
+ max_bounds_overflow_px: Math.max(
2497
+ 0,
2498
+ ...((frameSet && Array.isArray(frameSet.frames) ? frameSet.frames : [])).map((frame) => horizontalBoundsOverflowPx(frame)),
2499
+ ),
2500
+ })),
2501
+ })),
2160
2502
  route_inventory: currentViewports
2161
2503
  .filter((viewport) => viewport.route_inventory)
2162
2504
  .map((viewport) => ({