@riddledc/riddle-proof 0.7.33 → 0.7.35

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,21 @@ 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 `selector_text_order` when a table, list, or card group must show visible
206
+ items in a specific order after setup actions such as sorting or filtering:
207
+
208
+ ```json
209
+ {
210
+ "type": "selector_text_order",
211
+ "selector": ".game-table tbody tr",
212
+ "expected_texts": ["AAA Alpha", "MMM Middle", "ZZZ Omega"]
213
+ }
214
+ ```
215
+
216
+ The check records the visible text sequence for the selector and passes when
217
+ the expected texts appear in that order as a subsequence. This is less brittle
218
+ than matching one large body-text regex when only row or card order matters.
219
+
205
220
  Use the `route_inventory` check for source-page route coverage audits where a
206
221
  navigation surface must expose a known set of routes and each route must load
207
222
  both directly and through real link clicks:
@@ -220,14 +235,17 @@ both directly and through real link clicks:
220
235
  }
221
236
  ```
222
237
 
223
- The check records discovered source links, duplicate/missing/unexpected routes,
224
- direct route health, real clickthrough health, wrong-path failures, and stale
225
- source-surface failures. It runs direct/clickthrough sweeps on the first
226
- viewport by default and leaves ordinary profile overflow checks to cover the
227
- source page across all configured viewports. Set `run_direct_routes: false`,
228
- `run_clickthroughs: false`, `allow_unexpected_routes: true`, or
238
+ The check records discovered source links, unique source-link counts, duplicate
239
+ source-link counts, missing/unexpected routes, direct route health, real
240
+ clickthrough health, wrong-path failures, and stale source-surface failures. It
241
+ runs direct/clickthrough sweeps on the first viewport by default and leaves
242
+ ordinary profile overflow checks to cover the source page across all configured
243
+ viewports. Set `run_direct_routes: false`, `run_clickthroughs: false`,
244
+ `allow_unexpected_routes: true`, `require_unique_routes: false`, or
229
245
  `save_route_screenshots: true` when a profile needs a narrower or more
230
- artifact-heavy audit.
246
+ artifact-heavy audit. `require_unique_routes: false` is useful when a navigation
247
+ surface intentionally links to the same route from multiple cards or anchors,
248
+ while still proving the unique expected route set.
231
249
 
232
250
  The result uses `riddle-proof.profile-result.v1` and separates product failures
233
251
  from weak proof and environment blockers:
@@ -14,6 +14,7 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
14
14
  "route_loaded",
15
15
  "selector_visible",
16
16
  "selector_count_at_least",
17
+ "selector_text_order",
17
18
  "text_visible",
18
19
  "text_absent",
19
20
  "route_inventory",
@@ -293,6 +294,15 @@ function normalizeRouteInventoryRoutes(value, index) {
293
294
  }
294
295
  return routes;
295
296
  }
297
+ function normalizeExpectedTexts(value, index) {
298
+ if (value === void 0) return void 0;
299
+ if (!Array.isArray(value) || value.length === 0) {
300
+ throw new Error(`checks[${index}] selector_text_order expected_texts must be a non-empty array.`);
301
+ }
302
+ const texts = value.map((item) => String(item).replace(/\s+/g, " ").trim()).filter(Boolean);
303
+ if (!texts.length) throw new Error(`checks[${index}] selector_text_order expected_texts must contain non-empty strings.`);
304
+ return texts;
305
+ }
296
306
  function normalizeCheck(input, index) {
297
307
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
298
308
  const type = stringValue(input.type);
@@ -309,6 +319,11 @@ function normalizeCheck(input, index) {
309
319
  if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
310
320
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
311
321
  }
322
+ const expectedTexts = normalizeExpectedTexts(input.expected_texts ?? input.expectedTexts, index);
323
+ if (type === "selector_text_order") {
324
+ if (!stringValue(input.selector)) throw new Error(`checks[${index}] selector_text_order requires selector.`);
325
+ if (!expectedTexts?.length) throw new Error(`checks[${index}] selector_text_order requires expected_texts.`);
326
+ }
312
327
  const expectedRoutes = normalizeRouteInventoryRoutes(input.expected_routes ?? input.expectedRoutes, index);
313
328
  if (type === "route_inventory" && !expectedRoutes?.length) {
314
329
  throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
@@ -319,6 +334,7 @@ function normalizeCheck(input, index) {
319
334
  expected_path: stringValue(input.expected_path),
320
335
  expected_routes: expectedRoutes,
321
336
  selector: stringValue(input.selector),
337
+ expected_texts: expectedTexts,
322
338
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
323
339
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
324
340
  route_path_prefix: stringValue(input.route_path_prefix) || stringValue(input.routePathPrefix),
@@ -424,6 +440,28 @@ function selectorKey(check) {
424
440
  function textKey(check) {
425
441
  return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
426
442
  }
443
+ function textSequenceForCheck(viewport, check) {
444
+ const key = selectorKey(check);
445
+ const sequence = viewport.text_sequences?.[key];
446
+ if (isRecord(sequence)) {
447
+ const visibleTexts = Array.isArray(sequence.visible_texts) ? sequence.visible_texts : [];
448
+ const texts = Array.isArray(sequence.texts) ? sequence.texts : [];
449
+ const candidates = visibleTexts.length ? visibleTexts : texts;
450
+ return candidates.map((text) => String(text).replace(/\s+/g, " ").trim()).filter(Boolean);
451
+ }
452
+ return [];
453
+ }
454
+ function textOrderMatch(texts, expectedTexts) {
455
+ const positions = [];
456
+ let startAt = 0;
457
+ for (const expected of expectedTexts) {
458
+ const index = texts.findIndex((text, offset) => offset >= startAt && text.includes(expected));
459
+ if (index < 0) return { matched: false, positions };
460
+ positions.push(index);
461
+ startAt = index + 1;
462
+ }
463
+ return { matched: true, positions };
464
+ }
427
465
  function matchText(sample, check) {
428
466
  if (check.pattern) {
429
467
  try {
@@ -543,6 +581,32 @@ function assessCheckFromEvidence(check, evidence) {
543
581
  message: failed.length ? `Selector ${key} count was below ${minCount} in ${failed.length} viewport(s).` : void 0
544
582
  };
545
583
  }
584
+ if (check.type === "selector_text_order") {
585
+ const key = selectorKey(check);
586
+ const expectedTexts = check.expected_texts || [];
587
+ const results = viewports.map((viewport) => {
588
+ const texts = textSequenceForCheck(viewport, check);
589
+ const match = textOrderMatch(texts, expectedTexts);
590
+ return {
591
+ viewport: viewport.name,
592
+ matched: match.matched,
593
+ matched_positions: match.positions,
594
+ visible_texts: texts.slice(0, 20)
595
+ };
596
+ });
597
+ const failed = results.filter((result) => !result.matched).length;
598
+ return {
599
+ type: check.type,
600
+ label: checkLabel(check),
601
+ status: failed ? "failed" : "passed",
602
+ evidence: {
603
+ selector: key,
604
+ expected_texts: expectedTexts,
605
+ viewports: results.map((result) => toJsonValue(result))
606
+ },
607
+ message: failed ? `Selector ${key} text order failed in ${failed} viewport(s).` : void 0
608
+ };
609
+ }
546
610
  if (check.type === "text_visible" || check.type === "text_absent") {
547
611
  const key = textKey(check);
548
612
  const expectedVisible = check.type === "text_visible";
@@ -578,14 +642,23 @@ function assessCheckFromEvidence(check, evidence) {
578
642
  const first = inventories[0]?.inventory;
579
643
  const directRoutes = Array.isArray(first?.direct_routes) ? first.direct_routes : [];
580
644
  const clickthroughs = Array.isArray(first?.clickthroughs) ? first.clickthroughs : [];
645
+ const sourceLinkCount = numberValue(first?.source_link_count) ?? numberValue(first?.home_game_link_count) ?? null;
646
+ const sourceUniqueLinkCount = numberValue(first?.source_unique_link_count) ?? numberValue(first?.home_unique_game_link_count) ?? null;
647
+ const duplicateSourceLinks = Array.isArray(first?.duplicate_source_link_paths) ? first.duplicate_source_link_paths.map((path) => String(path)) : [];
648
+ const duplicateSourceLinkCount = numberValue(first?.duplicate_source_link_count) ?? (sourceLinkCount !== null && sourceUniqueLinkCount !== null ? Math.max(0, sourceLinkCount - sourceUniqueLinkCount) : null);
581
649
  return {
582
650
  type: check.type,
583
651
  label: checkLabel(check),
584
652
  status: failures.length ? "failed" : "passed",
585
653
  evidence: {
586
654
  expected_count: check.expected_routes?.length || 0,
587
- homepage_link_count: numberValue(first?.home_game_link_count) ?? null,
588
- homepage_unique_link_count: numberValue(first?.home_unique_game_link_count) ?? null,
655
+ source_link_count: sourceLinkCount,
656
+ source_unique_link_count: sourceUniqueLinkCount,
657
+ duplicate_source_link_count: duplicateSourceLinkCount,
658
+ duplicate_source_links: duplicateSourceLinks,
659
+ duplicates_allowed: check.require_unique_routes === false,
660
+ homepage_link_count: numberValue(first?.home_game_link_count) ?? sourceLinkCount,
661
+ homepage_unique_link_count: numberValue(first?.home_unique_game_link_count) ?? sourceUniqueLinkCount,
589
662
  direct_route_count: directRoutes.length,
590
663
  clickthrough_count: clickthroughs.length,
591
664
  failures
@@ -884,6 +957,28 @@ function textMatches(sample, check) {
884
957
  }
885
958
  return String(sample || "").includes(check.text || "");
886
959
  }
960
+ function textSequenceForCheck(viewport, check) {
961
+ const key = check.selector || "";
962
+ const sequence = viewport.text_sequences && viewport.text_sequences[key];
963
+ if (sequence && typeof sequence === "object") {
964
+ const visibleTexts = Array.isArray(sequence.visible_texts) ? sequence.visible_texts : [];
965
+ const texts = Array.isArray(sequence.texts) ? sequence.texts : [];
966
+ const candidates = visibleTexts.length ? visibleTexts : texts;
967
+ return candidates.map((text) => String(text || "").replace(/\s+/g, " ").trim()).filter(Boolean);
968
+ }
969
+ return [];
970
+ }
971
+ function textOrderMatch(texts, expectedTexts) {
972
+ const positions = [];
973
+ let startAt = 0;
974
+ for (const expected of expectedTexts || []) {
975
+ const index = texts.findIndex((text, offset) => offset >= startAt && text.includes(expected));
976
+ if (index < 0) return { matched: false, positions };
977
+ positions.push(index);
978
+ startAt = index + 1;
979
+ }
980
+ return { matched: true, positions };
981
+ }
887
982
  function numberValue(value) {
888
983
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
889
984
  }
@@ -1088,6 +1183,29 @@ function assessProfile(profile, evidence) {
1088
1183
  });
1089
1184
  continue;
1090
1185
  }
1186
+ if (check.type === "selector_text_order") {
1187
+ const selector = check.selector || "";
1188
+ const expectedTexts = check.expected_texts || [];
1189
+ const results = viewports.map((viewport) => {
1190
+ const texts = textSequenceForCheck(viewport, check);
1191
+ const match = textOrderMatch(texts, expectedTexts);
1192
+ return {
1193
+ viewport: viewport.name,
1194
+ matched: match.matched,
1195
+ matched_positions: match.positions,
1196
+ visible_texts: texts.slice(0, 20),
1197
+ };
1198
+ });
1199
+ const failed = results.filter((result) => !result.matched).length;
1200
+ checks.push({
1201
+ type: check.type,
1202
+ label: check.label || check.type,
1203
+ status: failed ? "failed" : "passed",
1204
+ evidence: { selector, expected_texts: expectedTexts, viewports: results },
1205
+ message: failed ? "Selector " + selector + " text order failed in " + failed + " viewport(s)." : undefined,
1206
+ });
1207
+ continue;
1208
+ }
1091
1209
  if (check.type === "text_visible" || check.type === "text_absent") {
1092
1210
  const key = check.pattern ? "pattern:" + check.pattern + "/" + (check.flags || "") : "text:" + (check.text || "");
1093
1211
  const expectedVisible = check.type === "text_visible";
@@ -1125,14 +1243,23 @@ function assessProfile(profile, evidence) {
1125
1243
  const first = inventories[0].inventory || {};
1126
1244
  const directRoutes = Array.isArray(first.direct_routes) ? first.direct_routes : [];
1127
1245
  const clickthroughs = Array.isArray(first.clickthroughs) ? first.clickthroughs : [];
1246
+ const sourceLinkCount = typeof first.source_link_count === "number" ? first.source_link_count : typeof first.home_game_link_count === "number" ? first.home_game_link_count : null;
1247
+ const sourceUniqueLinkCount = typeof first.source_unique_link_count === "number" ? first.source_unique_link_count : typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : null;
1248
+ const duplicateSourceLinks = Array.isArray(first.duplicate_source_link_paths) ? first.duplicate_source_link_paths.map((path) => String(path)) : [];
1249
+ const duplicateSourceLinkCount = typeof first.duplicate_source_link_count === "number" ? first.duplicate_source_link_count : sourceLinkCount !== null && sourceUniqueLinkCount !== null ? Math.max(0, sourceLinkCount - sourceUniqueLinkCount) : null;
1128
1250
  checks.push({
1129
1251
  type: check.type,
1130
1252
  label: check.label || check.type,
1131
1253
  status: failures.length ? "failed" : "passed",
1132
1254
  evidence: {
1133
1255
  expected_count: (check.expected_routes || []).length,
1134
- homepage_link_count: typeof first.home_game_link_count === "number" ? first.home_game_link_count : null,
1135
- homepage_unique_link_count: typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : null,
1256
+ source_link_count: sourceLinkCount,
1257
+ source_unique_link_count: sourceUniqueLinkCount,
1258
+ duplicate_source_link_count: duplicateSourceLinkCount,
1259
+ duplicate_source_links: duplicateSourceLinks,
1260
+ duplicates_allowed: check.require_unique_routes === false,
1261
+ homepage_link_count: typeof first.home_game_link_count === "number" ? first.home_game_link_count : sourceLinkCount,
1262
+ homepage_unique_link_count: typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : sourceUniqueLinkCount,
1136
1263
  direct_route_count: directRoutes.length,
1137
1264
  clickthrough_count: clickthroughs.length,
1138
1265
  failures,
@@ -1452,6 +1579,27 @@ async function selectorStats(selector) {
1452
1579
  return { count: elements.length, visible_count: elements.filter(isVisible).length };
1453
1580
  }).catch((error) => ({ count: 0, visible_count: 0, error: String(error && error.message ? error.message : error).slice(0, 500) }));
1454
1581
  }
1582
+ async function selectorTextSequence(selector) {
1583
+ return page.locator(selector).evaluateAll((elements) => {
1584
+ const compact = (value) => String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
1585
+ const isVisible = (element) => {
1586
+ const style = window.getComputedStyle(element);
1587
+ const rect = element.getBoundingClientRect();
1588
+ return style && style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
1589
+ };
1590
+ const rows = elements.map((element, index) => ({
1591
+ index,
1592
+ text: compact(element.innerText || element.textContent || ""),
1593
+ visible: isVisible(element),
1594
+ }));
1595
+ return {
1596
+ count: rows.length,
1597
+ visible_count: rows.filter((row) => row.visible).length,
1598
+ texts: rows.map((row) => row.text).filter(Boolean).slice(0, 40),
1599
+ visible_texts: rows.filter((row) => row.visible).map((row) => row.text).filter(Boolean).slice(0, 40),
1600
+ };
1601
+ }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
1602
+ }
1455
1603
  function inventoryAppPathFromUrl(urlLike) {
1456
1604
  const url = new URL(String(urlLike), targetUrl);
1457
1605
  const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
@@ -1651,8 +1799,9 @@ async function collectRouteInventory(check, viewport) {
1651
1799
  const homeLinkPaths = homeLinks.map((link) => link.app_path);
1652
1800
  const uniqueHomeLinkPaths = Array.from(new Set(homeLinkPaths));
1653
1801
  const duplicateHomeLinkPaths = homeLinkPaths.filter((path, index) => homeLinkPaths.indexOf(path) !== index);
1802
+ const duplicateHomeLinkPathSet = Array.from(new Set(duplicateHomeLinkPaths));
1654
1803
  if (check.require_unique_routes !== false && duplicateHomeLinkPaths.length) {
1655
- failures.push({ code: "duplicate_source_links", paths: Array.from(new Set(duplicateHomeLinkPaths)) });
1804
+ failures.push({ code: "duplicate_source_links", paths: duplicateHomeLinkPathSet });
1656
1805
  }
1657
1806
  for (const route of expectedRoutes) {
1658
1807
  if (!homeLinkPaths.includes(normalizeRoutePath(route.path))) {
@@ -1728,6 +1877,11 @@ async function collectRouteInventory(check, viewport) {
1728
1877
  expected_routes: expectedRoutes,
1729
1878
  link_selector: check.link_selector || "a[href]",
1730
1879
  source_selector: check.source_selector || null,
1880
+ source_link_count: homeLinkPaths.length,
1881
+ source_unique_link_count: uniqueHomeLinkPaths.length,
1882
+ duplicate_source_link_count: duplicateHomeLinkPaths.length,
1883
+ duplicate_source_link_paths: duplicateHomeLinkPathSet,
1884
+ duplicates_allowed: check.require_unique_routes === false,
1731
1885
  home_game_link_count: homeLinkPaths.length,
1732
1886
  home_unique_game_link_count: uniqueHomeLinkPaths.length,
1733
1887
  home_links: homeLinks,
@@ -1843,11 +1997,16 @@ async function captureViewport(viewport) {
1843
1997
  evaluation_error: String(error && error.message ? error.message : error).slice(0, 1000),
1844
1998
  }));
1845
1999
  const selectors = {};
2000
+ const text_sequences = {};
1846
2001
  const text_matches = {};
1847
2002
  for (const check of profile.checks || []) {
1848
2003
  if ((check.type === "selector_visible" || check.type === "selector_count_at_least") && check.selector) {
1849
2004
  selectors[check.selector] = await selectorStats(check.selector);
1850
2005
  }
2006
+ if (check.type === "selector_text_order" && check.selector) {
2007
+ selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
2008
+ text_sequences[check.selector] = await selectorTextSequence(check.selector);
2009
+ }
1851
2010
  if ((check.type === "text_visible" || check.type === "text_absent") && (check.text || check.pattern)) {
1852
2011
  text_matches[textKey(check)] = textMatches(dom.body_text || dom.body_text_sample || "", check);
1853
2012
  }
@@ -1871,6 +2030,11 @@ async function captureViewport(viewport) {
1871
2030
  expected_routes: routeInventoryCheck.expected_routes || [],
1872
2031
  link_selector: routeInventoryCheck.link_selector || "a[href]",
1873
2032
  source_selector: routeInventoryCheck.source_selector || null,
2033
+ source_link_count: 0,
2034
+ source_unique_link_count: 0,
2035
+ duplicate_source_link_count: 0,
2036
+ duplicate_source_link_paths: [],
2037
+ duplicates_allowed: routeInventoryCheck.require_unique_routes === false,
1874
2038
  home_game_link_count: 0,
1875
2039
  home_unique_game_link_count: 0,
1876
2040
  home_links: [],
@@ -1906,6 +2070,7 @@ async function captureViewport(viewport) {
1906
2070
  bounds_overflow_px: dom.bounds_overflow_px,
1907
2071
  overflow_offenders: dom.overflow_offenders || [],
1908
2072
  selectors,
2073
+ text_sequences,
1909
2074
  text_matches,
1910
2075
  route_inventory: routeInventory,
1911
2076
  setup_action_results: setupActionResults,
@@ -1945,6 +2110,9 @@ function buildProfileEvidence(currentViewports) {
1945
2110
  .map((viewport) => ({
1946
2111
  viewport: viewport.name,
1947
2112
  expected_count: (viewport.route_inventory.expected_routes || []).length,
2113
+ source_link_count: viewport.route_inventory.source_link_count == null ? viewport.route_inventory.home_game_link_count : viewport.route_inventory.source_link_count,
2114
+ source_unique_link_count: viewport.route_inventory.source_unique_link_count == null ? viewport.route_inventory.home_unique_game_link_count : viewport.route_inventory.source_unique_link_count,
2115
+ duplicate_source_link_count: viewport.route_inventory.duplicate_source_link_count,
1948
2116
  home_unique_game_link_count: viewport.route_inventory.home_unique_game_link_count,
1949
2117
  direct_route_count: (viewport.route_inventory.direct_routes || []).length,
1950
2118
  clickthrough_count: (viewport.route_inventory.clickthroughs || []).length,
package/dist/cli.cjs CHANGED
@@ -6887,6 +6887,7 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
6887
6887
  "route_loaded",
6888
6888
  "selector_visible",
6889
6889
  "selector_count_at_least",
6890
+ "selector_text_order",
6890
6891
  "text_visible",
6891
6892
  "text_absent",
6892
6893
  "route_inventory",
@@ -7166,6 +7167,15 @@ function normalizeRouteInventoryRoutes(value, index) {
7166
7167
  }
7167
7168
  return routes;
7168
7169
  }
7170
+ function normalizeExpectedTexts(value, index) {
7171
+ if (value === void 0) return void 0;
7172
+ if (!Array.isArray(value) || value.length === 0) {
7173
+ throw new Error(`checks[${index}] selector_text_order expected_texts must be a non-empty array.`);
7174
+ }
7175
+ const texts = value.map((item) => String(item).replace(/\s+/g, " ").trim()).filter(Boolean);
7176
+ if (!texts.length) throw new Error(`checks[${index}] selector_text_order expected_texts must contain non-empty strings.`);
7177
+ return texts;
7178
+ }
7169
7179
  function normalizeCheck(input, index) {
7170
7180
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
7171
7181
  const type = stringValue2(input.type);
@@ -7182,6 +7192,11 @@ function normalizeCheck(input, index) {
7182
7192
  if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
7183
7193
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
7184
7194
  }
7195
+ const expectedTexts = normalizeExpectedTexts(input.expected_texts ?? input.expectedTexts, index);
7196
+ if (type === "selector_text_order") {
7197
+ if (!stringValue2(input.selector)) throw new Error(`checks[${index}] selector_text_order requires selector.`);
7198
+ if (!expectedTexts?.length) throw new Error(`checks[${index}] selector_text_order requires expected_texts.`);
7199
+ }
7185
7200
  const expectedRoutes = normalizeRouteInventoryRoutes(input.expected_routes ?? input.expectedRoutes, index);
7186
7201
  if (type === "route_inventory" && !expectedRoutes?.length) {
7187
7202
  throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
@@ -7192,6 +7207,7 @@ function normalizeCheck(input, index) {
7192
7207
  expected_path: stringValue2(input.expected_path),
7193
7208
  expected_routes: expectedRoutes,
7194
7209
  selector: stringValue2(input.selector),
7210
+ expected_texts: expectedTexts,
7195
7211
  link_selector: stringValue2(input.link_selector) || stringValue2(input.linkSelector),
7196
7212
  source_selector: stringValue2(input.source_selector) || stringValue2(input.sourceSelector),
7197
7213
  route_path_prefix: stringValue2(input.route_path_prefix) || stringValue2(input.routePathPrefix),
@@ -7297,6 +7313,28 @@ function selectorKey(check) {
7297
7313
  function textKey(check) {
7298
7314
  return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
7299
7315
  }
7316
+ function textSequenceForCheck(viewport, check) {
7317
+ const key = selectorKey(check);
7318
+ const sequence = viewport.text_sequences?.[key];
7319
+ if (isRecord(sequence)) {
7320
+ const visibleTexts = Array.isArray(sequence.visible_texts) ? sequence.visible_texts : [];
7321
+ const texts = Array.isArray(sequence.texts) ? sequence.texts : [];
7322
+ const candidates = visibleTexts.length ? visibleTexts : texts;
7323
+ return candidates.map((text) => String(text).replace(/\s+/g, " ").trim()).filter(Boolean);
7324
+ }
7325
+ return [];
7326
+ }
7327
+ function textOrderMatch(texts, expectedTexts) {
7328
+ const positions = [];
7329
+ let startAt = 0;
7330
+ for (const expected of expectedTexts) {
7331
+ const index = texts.findIndex((text, offset) => offset >= startAt && text.includes(expected));
7332
+ if (index < 0) return { matched: false, positions };
7333
+ positions.push(index);
7334
+ startAt = index + 1;
7335
+ }
7336
+ return { matched: true, positions };
7337
+ }
7300
7338
  function matchText(sample, check) {
7301
7339
  if (check.pattern) {
7302
7340
  try {
@@ -7416,6 +7454,32 @@ function assessCheckFromEvidence(check, evidence) {
7416
7454
  message: failed.length ? `Selector ${key} count was below ${minCount} in ${failed.length} viewport(s).` : void 0
7417
7455
  };
7418
7456
  }
7457
+ if (check.type === "selector_text_order") {
7458
+ const key = selectorKey(check);
7459
+ const expectedTexts = check.expected_texts || [];
7460
+ const results = viewports.map((viewport) => {
7461
+ const texts = textSequenceForCheck(viewport, check);
7462
+ const match = textOrderMatch(texts, expectedTexts);
7463
+ return {
7464
+ viewport: viewport.name,
7465
+ matched: match.matched,
7466
+ matched_positions: match.positions,
7467
+ visible_texts: texts.slice(0, 20)
7468
+ };
7469
+ });
7470
+ const failed = results.filter((result) => !result.matched).length;
7471
+ return {
7472
+ type: check.type,
7473
+ label: checkLabel(check),
7474
+ status: failed ? "failed" : "passed",
7475
+ evidence: {
7476
+ selector: key,
7477
+ expected_texts: expectedTexts,
7478
+ viewports: results.map((result) => toJsonValue(result))
7479
+ },
7480
+ message: failed ? `Selector ${key} text order failed in ${failed} viewport(s).` : void 0
7481
+ };
7482
+ }
7419
7483
  if (check.type === "text_visible" || check.type === "text_absent") {
7420
7484
  const key = textKey(check);
7421
7485
  const expectedVisible = check.type === "text_visible";
@@ -7451,14 +7515,23 @@ function assessCheckFromEvidence(check, evidence) {
7451
7515
  const first = inventories[0]?.inventory;
7452
7516
  const directRoutes = Array.isArray(first?.direct_routes) ? first.direct_routes : [];
7453
7517
  const clickthroughs = Array.isArray(first?.clickthroughs) ? first.clickthroughs : [];
7518
+ const sourceLinkCount = numberValue(first?.source_link_count) ?? numberValue(first?.home_game_link_count) ?? null;
7519
+ const sourceUniqueLinkCount = numberValue(first?.source_unique_link_count) ?? numberValue(first?.home_unique_game_link_count) ?? null;
7520
+ const duplicateSourceLinks = Array.isArray(first?.duplicate_source_link_paths) ? first.duplicate_source_link_paths.map((path7) => String(path7)) : [];
7521
+ const duplicateSourceLinkCount = numberValue(first?.duplicate_source_link_count) ?? (sourceLinkCount !== null && sourceUniqueLinkCount !== null ? Math.max(0, sourceLinkCount - sourceUniqueLinkCount) : null);
7454
7522
  return {
7455
7523
  type: check.type,
7456
7524
  label: checkLabel(check),
7457
7525
  status: failures.length ? "failed" : "passed",
7458
7526
  evidence: {
7459
7527
  expected_count: check.expected_routes?.length || 0,
7460
- homepage_link_count: numberValue(first?.home_game_link_count) ?? null,
7461
- homepage_unique_link_count: numberValue(first?.home_unique_game_link_count) ?? null,
7528
+ source_link_count: sourceLinkCount,
7529
+ source_unique_link_count: sourceUniqueLinkCount,
7530
+ duplicate_source_link_count: duplicateSourceLinkCount,
7531
+ duplicate_source_links: duplicateSourceLinks,
7532
+ duplicates_allowed: check.require_unique_routes === false,
7533
+ homepage_link_count: numberValue(first?.home_game_link_count) ?? sourceLinkCount,
7534
+ homepage_unique_link_count: numberValue(first?.home_unique_game_link_count) ?? sourceUniqueLinkCount,
7462
7535
  direct_route_count: directRoutes.length,
7463
7536
  clickthrough_count: clickthroughs.length,
7464
7537
  failures
@@ -7741,6 +7814,28 @@ function textMatches(sample, check) {
7741
7814
  }
7742
7815
  return String(sample || "").includes(check.text || "");
7743
7816
  }
7817
+ function textSequenceForCheck(viewport, check) {
7818
+ const key = check.selector || "";
7819
+ const sequence = viewport.text_sequences && viewport.text_sequences[key];
7820
+ if (sequence && typeof sequence === "object") {
7821
+ const visibleTexts = Array.isArray(sequence.visible_texts) ? sequence.visible_texts : [];
7822
+ const texts = Array.isArray(sequence.texts) ? sequence.texts : [];
7823
+ const candidates = visibleTexts.length ? visibleTexts : texts;
7824
+ return candidates.map((text) => String(text || "").replace(/\s+/g, " ").trim()).filter(Boolean);
7825
+ }
7826
+ return [];
7827
+ }
7828
+ function textOrderMatch(texts, expectedTexts) {
7829
+ const positions = [];
7830
+ let startAt = 0;
7831
+ for (const expected of expectedTexts || []) {
7832
+ const index = texts.findIndex((text, offset) => offset >= startAt && text.includes(expected));
7833
+ if (index < 0) return { matched: false, positions };
7834
+ positions.push(index);
7835
+ startAt = index + 1;
7836
+ }
7837
+ return { matched: true, positions };
7838
+ }
7744
7839
  function numberValue(value) {
7745
7840
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
7746
7841
  }
@@ -7945,6 +8040,29 @@ function assessProfile(profile, evidence) {
7945
8040
  });
7946
8041
  continue;
7947
8042
  }
8043
+ if (check.type === "selector_text_order") {
8044
+ const selector = check.selector || "";
8045
+ const expectedTexts = check.expected_texts || [];
8046
+ const results = viewports.map((viewport) => {
8047
+ const texts = textSequenceForCheck(viewport, check);
8048
+ const match = textOrderMatch(texts, expectedTexts);
8049
+ return {
8050
+ viewport: viewport.name,
8051
+ matched: match.matched,
8052
+ matched_positions: match.positions,
8053
+ visible_texts: texts.slice(0, 20),
8054
+ };
8055
+ });
8056
+ const failed = results.filter((result) => !result.matched).length;
8057
+ checks.push({
8058
+ type: check.type,
8059
+ label: check.label || check.type,
8060
+ status: failed ? "failed" : "passed",
8061
+ evidence: { selector, expected_texts: expectedTexts, viewports: results },
8062
+ message: failed ? "Selector " + selector + " text order failed in " + failed + " viewport(s)." : undefined,
8063
+ });
8064
+ continue;
8065
+ }
7948
8066
  if (check.type === "text_visible" || check.type === "text_absent") {
7949
8067
  const key = check.pattern ? "pattern:" + check.pattern + "/" + (check.flags || "") : "text:" + (check.text || "");
7950
8068
  const expectedVisible = check.type === "text_visible";
@@ -7982,14 +8100,23 @@ function assessProfile(profile, evidence) {
7982
8100
  const first = inventories[0].inventory || {};
7983
8101
  const directRoutes = Array.isArray(first.direct_routes) ? first.direct_routes : [];
7984
8102
  const clickthroughs = Array.isArray(first.clickthroughs) ? first.clickthroughs : [];
8103
+ const sourceLinkCount = typeof first.source_link_count === "number" ? first.source_link_count : typeof first.home_game_link_count === "number" ? first.home_game_link_count : null;
8104
+ const sourceUniqueLinkCount = typeof first.source_unique_link_count === "number" ? first.source_unique_link_count : typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : null;
8105
+ const duplicateSourceLinks = Array.isArray(first.duplicate_source_link_paths) ? first.duplicate_source_link_paths.map((path) => String(path)) : [];
8106
+ const duplicateSourceLinkCount = typeof first.duplicate_source_link_count === "number" ? first.duplicate_source_link_count : sourceLinkCount !== null && sourceUniqueLinkCount !== null ? Math.max(0, sourceLinkCount - sourceUniqueLinkCount) : null;
7985
8107
  checks.push({
7986
8108
  type: check.type,
7987
8109
  label: check.label || check.type,
7988
8110
  status: failures.length ? "failed" : "passed",
7989
8111
  evidence: {
7990
8112
  expected_count: (check.expected_routes || []).length,
7991
- homepage_link_count: typeof first.home_game_link_count === "number" ? first.home_game_link_count : null,
7992
- homepage_unique_link_count: typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : null,
8113
+ source_link_count: sourceLinkCount,
8114
+ source_unique_link_count: sourceUniqueLinkCount,
8115
+ duplicate_source_link_count: duplicateSourceLinkCount,
8116
+ duplicate_source_links: duplicateSourceLinks,
8117
+ duplicates_allowed: check.require_unique_routes === false,
8118
+ homepage_link_count: typeof first.home_game_link_count === "number" ? first.home_game_link_count : sourceLinkCount,
8119
+ homepage_unique_link_count: typeof first.home_unique_game_link_count === "number" ? first.home_unique_game_link_count : sourceUniqueLinkCount,
7993
8120
  direct_route_count: directRoutes.length,
7994
8121
  clickthrough_count: clickthroughs.length,
7995
8122
  failures,
@@ -8309,6 +8436,27 @@ async function selectorStats(selector) {
8309
8436
  return { count: elements.length, visible_count: elements.filter(isVisible).length };
8310
8437
  }).catch((error) => ({ count: 0, visible_count: 0, error: String(error && error.message ? error.message : error).slice(0, 500) }));
8311
8438
  }
8439
+ async function selectorTextSequence(selector) {
8440
+ return page.locator(selector).evaluateAll((elements) => {
8441
+ const compact = (value) => String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
8442
+ const isVisible = (element) => {
8443
+ const style = window.getComputedStyle(element);
8444
+ const rect = element.getBoundingClientRect();
8445
+ return style && style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
8446
+ };
8447
+ const rows = elements.map((element, index) => ({
8448
+ index,
8449
+ text: compact(element.innerText || element.textContent || ""),
8450
+ visible: isVisible(element),
8451
+ }));
8452
+ return {
8453
+ count: rows.length,
8454
+ visible_count: rows.filter((row) => row.visible).length,
8455
+ texts: rows.map((row) => row.text).filter(Boolean).slice(0, 40),
8456
+ visible_texts: rows.filter((row) => row.visible).map((row) => row.text).filter(Boolean).slice(0, 40),
8457
+ };
8458
+ }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
8459
+ }
8312
8460
  function inventoryAppPathFromUrl(urlLike) {
8313
8461
  const url = new URL(String(urlLike), targetUrl);
8314
8462
  const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
@@ -8508,8 +8656,9 @@ async function collectRouteInventory(check, viewport) {
8508
8656
  const homeLinkPaths = homeLinks.map((link) => link.app_path);
8509
8657
  const uniqueHomeLinkPaths = Array.from(new Set(homeLinkPaths));
8510
8658
  const duplicateHomeLinkPaths = homeLinkPaths.filter((path, index) => homeLinkPaths.indexOf(path) !== index);
8659
+ const duplicateHomeLinkPathSet = Array.from(new Set(duplicateHomeLinkPaths));
8511
8660
  if (check.require_unique_routes !== false && duplicateHomeLinkPaths.length) {
8512
- failures.push({ code: "duplicate_source_links", paths: Array.from(new Set(duplicateHomeLinkPaths)) });
8661
+ failures.push({ code: "duplicate_source_links", paths: duplicateHomeLinkPathSet });
8513
8662
  }
8514
8663
  for (const route of expectedRoutes) {
8515
8664
  if (!homeLinkPaths.includes(normalizeRoutePath(route.path))) {
@@ -8585,6 +8734,11 @@ async function collectRouteInventory(check, viewport) {
8585
8734
  expected_routes: expectedRoutes,
8586
8735
  link_selector: check.link_selector || "a[href]",
8587
8736
  source_selector: check.source_selector || null,
8737
+ source_link_count: homeLinkPaths.length,
8738
+ source_unique_link_count: uniqueHomeLinkPaths.length,
8739
+ duplicate_source_link_count: duplicateHomeLinkPaths.length,
8740
+ duplicate_source_link_paths: duplicateHomeLinkPathSet,
8741
+ duplicates_allowed: check.require_unique_routes === false,
8588
8742
  home_game_link_count: homeLinkPaths.length,
8589
8743
  home_unique_game_link_count: uniqueHomeLinkPaths.length,
8590
8744
  home_links: homeLinks,
@@ -8700,11 +8854,16 @@ async function captureViewport(viewport) {
8700
8854
  evaluation_error: String(error && error.message ? error.message : error).slice(0, 1000),
8701
8855
  }));
8702
8856
  const selectors = {};
8857
+ const text_sequences = {};
8703
8858
  const text_matches = {};
8704
8859
  for (const check of profile.checks || []) {
8705
8860
  if ((check.type === "selector_visible" || check.type === "selector_count_at_least") && check.selector) {
8706
8861
  selectors[check.selector] = await selectorStats(check.selector);
8707
8862
  }
8863
+ if (check.type === "selector_text_order" && check.selector) {
8864
+ selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
8865
+ text_sequences[check.selector] = await selectorTextSequence(check.selector);
8866
+ }
8708
8867
  if ((check.type === "text_visible" || check.type === "text_absent") && (check.text || check.pattern)) {
8709
8868
  text_matches[textKey(check)] = textMatches(dom.body_text || dom.body_text_sample || "", check);
8710
8869
  }
@@ -8728,6 +8887,11 @@ async function captureViewport(viewport) {
8728
8887
  expected_routes: routeInventoryCheck.expected_routes || [],
8729
8888
  link_selector: routeInventoryCheck.link_selector || "a[href]",
8730
8889
  source_selector: routeInventoryCheck.source_selector || null,
8890
+ source_link_count: 0,
8891
+ source_unique_link_count: 0,
8892
+ duplicate_source_link_count: 0,
8893
+ duplicate_source_link_paths: [],
8894
+ duplicates_allowed: routeInventoryCheck.require_unique_routes === false,
8731
8895
  home_game_link_count: 0,
8732
8896
  home_unique_game_link_count: 0,
8733
8897
  home_links: [],
@@ -8763,6 +8927,7 @@ async function captureViewport(viewport) {
8763
8927
  bounds_overflow_px: dom.bounds_overflow_px,
8764
8928
  overflow_offenders: dom.overflow_offenders || [],
8765
8929
  selectors,
8930
+ text_sequences,
8766
8931
  text_matches,
8767
8932
  route_inventory: routeInventory,
8768
8933
  setup_action_results: setupActionResults,
@@ -8802,6 +8967,9 @@ function buildProfileEvidence(currentViewports) {
8802
8967
  .map((viewport) => ({
8803
8968
  viewport: viewport.name,
8804
8969
  expected_count: (viewport.route_inventory.expected_routes || []).length,
8970
+ source_link_count: viewport.route_inventory.source_link_count == null ? viewport.route_inventory.home_game_link_count : viewport.route_inventory.source_link_count,
8971
+ source_unique_link_count: viewport.route_inventory.source_unique_link_count == null ? viewport.route_inventory.home_unique_game_link_count : viewport.route_inventory.source_unique_link_count,
8972
+ duplicate_source_link_count: viewport.route_inventory.duplicate_source_link_count,
8805
8973
  home_unique_game_link_count: viewport.route_inventory.home_unique_game_link_count,
8806
8974
  direct_route_count: (viewport.route_inventory.direct_routes || []).length,
8807
8975
  clickthrough_count: (viewport.route_inventory.clickthroughs || []).length,