@riddledc/riddle-proof 0.7.30 → 0.7.32

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
@@ -198,6 +198,33 @@ clears `local`, `session`, or `both` browser storage scopes, defaults to
198
198
  profile carries its own hosted Riddle worker budget; an explicit CLI `--timeout`
199
199
  still overrides the profile value for one-off runs.
200
200
 
201
+ Use the `route_inventory` check for source-page route coverage audits where a
202
+ navigation surface must expose a known set of routes and each route must load
203
+ both directly and through real link clicks:
204
+
205
+ ```json
206
+ {
207
+ "type": "route_inventory",
208
+ "expected_routes": [
209
+ { "name": "Gem Mine", "path": "/games/gem-mine" },
210
+ { "name": "Coin Clicker", "path": "/games/coin-clicker" }
211
+ ],
212
+ "link_selector": "a[href^='/games/']",
213
+ "source_selector": ".game-table",
214
+ "route_path_prefix": "/games/",
215
+ "timeout_ms": 45000
216
+ }
217
+ ```
218
+
219
+ The check records discovered source links, duplicate/missing/unexpected routes,
220
+ direct route health, real clickthrough health, wrong-path failures, and stale
221
+ source-surface failures. It runs direct/clickthrough sweeps on the first
222
+ viewport by default and leaves ordinary profile overflow checks to cover the
223
+ source page across all configured viewports. Set `run_direct_routes: false`,
224
+ `run_clickthroughs: false`, `allow_unexpected_routes: true`, or
225
+ `save_route_screenshots: true` when a profile needs a narrower or more
226
+ artifact-heavy audit.
227
+
201
228
  The result uses `riddle-proof.profile-result.v1` and separates product failures
202
229
  from weak proof and environment blockers:
203
230
 
@@ -16,6 +16,7 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
16
16
  "selector_count_at_least",
17
17
  "text_visible",
18
18
  "text_absent",
19
+ "route_inventory",
19
20
  "no_horizontal_overflow",
20
21
  "no_mobile_horizontal_overflow",
21
22
  "no_fatal_console_errors"
@@ -265,6 +266,33 @@ function normalizeNetworkMocks(value) {
265
266
  if (!Array.isArray(value)) throw new Error("target.network_mocks must be an array.");
266
267
  return value.map(normalizeNetworkMock);
267
268
  }
269
+ function normalizeRouteInventoryPath(value, label) {
270
+ const path = stringValue(value);
271
+ if (!path) throw new Error(`${label} requires path.`);
272
+ if (!path.startsWith("/")) throw new Error(`${label}.path must start with /.`);
273
+ return normalizeRoutePath(path);
274
+ }
275
+ function normalizeRouteInventoryRoute(input, index) {
276
+ if (typeof input === "string") return { path: normalizeRouteInventoryPath(input, `checks route_inventory expected_routes[${index}]`) };
277
+ if (!isRecord(input)) throw new Error(`checks route_inventory expected_routes[${index}] must be a string or object.`);
278
+ return {
279
+ name: stringValue(input.name),
280
+ path: normalizeRouteInventoryPath(input.path, `checks route_inventory expected_routes[${index}]`)
281
+ };
282
+ }
283
+ function normalizeRouteInventoryRoutes(value, index) {
284
+ if (value === void 0) return void 0;
285
+ if (!Array.isArray(value) || value.length === 0) {
286
+ throw new Error(`checks[${index}] route_inventory expected_routes must be a non-empty array.`);
287
+ }
288
+ const routes = value.map(normalizeRouteInventoryRoute);
289
+ const seen = /* @__PURE__ */ new Set();
290
+ for (const route of routes) {
291
+ if (seen.has(route.path)) throw new Error(`checks[${index}] route_inventory expected_routes contains duplicate path ${route.path}.`);
292
+ seen.add(route.path);
293
+ }
294
+ return routes;
295
+ }
268
296
  function normalizeCheck(input, index) {
269
297
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
270
298
  const type = stringValue(input.type);
@@ -281,16 +309,34 @@ function normalizeCheck(input, index) {
281
309
  if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
282
310
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
283
311
  }
312
+ const expectedRoutes = normalizeRouteInventoryRoutes(input.expected_routes ?? input.expectedRoutes, index);
313
+ if (type === "route_inventory" && !expectedRoutes?.length) {
314
+ throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
315
+ }
284
316
  return {
285
317
  type,
286
318
  label: stringValue(input.label),
287
319
  expected_path: stringValue(input.expected_path),
320
+ expected_routes: expectedRoutes,
288
321
  selector: stringValue(input.selector),
322
+ link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
323
+ source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
324
+ route_path_prefix: stringValue(input.route_path_prefix) || stringValue(input.routePathPrefix),
325
+ route_ready_selector: stringValue(input.route_ready_selector) || stringValue(input.routeReadySelector),
326
+ route_ready_text: stringValue(input.route_ready_text) || stringValue(input.routeReadyText),
327
+ route_ready_pattern: stringValue(input.route_ready_pattern) || stringValue(input.routeReadyPattern),
328
+ route_ready_flags: stringValue(input.route_ready_flags) || stringValue(input.routeReadyFlags),
289
329
  text: stringValue(input.text),
290
330
  pattern: stringValue(input.pattern),
291
331
  flags: stringValue(input.flags),
292
332
  min_count: numberValue(input.min_count),
293
- max_overflow_px: numberValue(input.max_overflow_px)
333
+ max_overflow_px: numberValue(input.max_overflow_px),
334
+ timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
335
+ run_direct_routes: input.run_direct_routes === false || input.runDirectRoutes === false ? false : true,
336
+ run_clickthroughs: input.run_clickthroughs === false || input.runClickthroughs === false ? false : true,
337
+ require_unique_routes: input.require_unique_routes === false || input.requireUniqueRoutes === false ? false : true,
338
+ allow_unexpected_routes: input.allow_unexpected_routes === true || input.allowUnexpectedRoutes === true,
339
+ save_route_screenshots: input.save_route_screenshots === true || input.saveRouteScreenshots === true
294
340
  };
295
341
  }
296
342
  function normalizeFailurePolicy(input) {
@@ -517,6 +563,36 @@ function assessCheckFromEvidence(check, evidence) {
517
563
  message: failed ? `Text assertion failed in ${failed} viewport(s).` : void 0
518
564
  };
519
565
  }
566
+ if (check.type === "route_inventory") {
567
+ const inventories = viewports.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory })).filter((item) => isRecord(item.inventory));
568
+ if (!inventories.length) {
569
+ return {
570
+ type: check.type,
571
+ label: checkLabel(check),
572
+ status: "failed",
573
+ evidence: { expected_count: check.expected_routes?.length || 0 },
574
+ message: "No route inventory evidence was captured."
575
+ };
576
+ }
577
+ const failures = inventories.flatMap((item) => Array.isArray(item.inventory?.failures) ? item.inventory.failures.map((failure) => toJsonValue({ viewport: item.viewport, failure })) : []);
578
+ const first = inventories[0]?.inventory;
579
+ const directRoutes = Array.isArray(first?.direct_routes) ? first.direct_routes : [];
580
+ const clickthroughs = Array.isArray(first?.clickthroughs) ? first.clickthroughs : [];
581
+ return {
582
+ type: check.type,
583
+ label: checkLabel(check),
584
+ status: failures.length ? "failed" : "passed",
585
+ evidence: {
586
+ 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,
589
+ direct_route_count: directRoutes.length,
590
+ clickthrough_count: clickthroughs.length,
591
+ failures
592
+ },
593
+ message: failures.length ? `Route inventory failed with ${failures.length} issue(s).` : void 0
594
+ };
595
+ }
520
596
  if (check.type === "no_horizontal_overflow" || check.type === "no_mobile_horizontal_overflow") {
521
597
  const maxOverflow = check.max_overflow_px ?? 4;
522
598
  const applicable = check.type === "no_mobile_horizontal_overflow" ? viewports.filter((viewport) => viewport.width <= 820) : viewports;
@@ -1026,6 +1102,45 @@ function assessProfile(profile, evidence) {
1026
1102
  });
1027
1103
  continue;
1028
1104
  }
1105
+ if (check.type === "route_inventory") {
1106
+ const inventories = viewports
1107
+ .map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
1108
+ .filter((item) => item.inventory && typeof item.inventory === "object");
1109
+ if (!inventories.length) {
1110
+ checks.push({
1111
+ type: check.type,
1112
+ label: check.label || check.type,
1113
+ status: "failed",
1114
+ evidence: { expected_count: (check.expected_routes || []).length },
1115
+ message: "No route inventory evidence was captured.",
1116
+ });
1117
+ continue;
1118
+ }
1119
+ const failures = [];
1120
+ for (const item of inventories) {
1121
+ for (const failure of Array.isArray(item.inventory.failures) ? item.inventory.failures : []) {
1122
+ failures.push({ viewport: item.viewport, failure });
1123
+ }
1124
+ }
1125
+ const first = inventories[0].inventory || {};
1126
+ const directRoutes = Array.isArray(first.direct_routes) ? first.direct_routes : [];
1127
+ const clickthroughs = Array.isArray(first.clickthroughs) ? first.clickthroughs : [];
1128
+ checks.push({
1129
+ type: check.type,
1130
+ label: check.label || check.type,
1131
+ status: failures.length ? "failed" : "passed",
1132
+ evidence: {
1133
+ 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,
1136
+ direct_route_count: directRoutes.length,
1137
+ clickthrough_count: clickthroughs.length,
1138
+ failures,
1139
+ },
1140
+ message: failures.length ? "Route inventory failed with " + failures.length + " issue(s)." : undefined,
1141
+ });
1142
+ continue;
1143
+ }
1029
1144
  if (check.type === "no_horizontal_overflow" || check.type === "no_mobile_horizontal_overflow") {
1030
1145
  const maxOverflow = check.max_overflow_px == null ? 4 : check.max_overflow_px;
1031
1146
  const applicable = check.type === "no_mobile_horizontal_overflow" ? viewports.filter((viewport) => viewport.width <= 820) : viewports;
@@ -1277,7 +1392,7 @@ async function executeSetupAction(action, ordinal) {
1277
1392
  if (targetIndex < 0) return { ...base, reason: "text_not_found", count };
1278
1393
  }
1279
1394
  if (targetIndex < 0 || targetIndex >= count) return { ...base, reason: "index_out_of_range", count, target_index: targetIndex };
1280
- await locator.nth(targetIndex).click({ timeout });
1395
+ await locator.nth(targetIndex).click({ timeout, noWaitAfter: true });
1281
1396
  return { ...base, ok: true, count, target_index: targetIndex, text: matchedText };
1282
1397
  }
1283
1398
  if (type === "fill" || type === "set_input_value") {
@@ -1337,6 +1452,290 @@ async function selectorStats(selector) {
1337
1452
  return { count: elements.length, visible_count: elements.filter(isVisible).length };
1338
1453
  }).catch((error) => ({ count: 0, visible_count: 0, error: String(error && error.message ? error.message : error).slice(0, 500) }));
1339
1454
  }
1455
+ function inventoryAppPathFromUrl(urlLike) {
1456
+ const url = new URL(String(urlLike), targetUrl);
1457
+ const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
1458
+ let pathname = url.pathname || "/";
1459
+ if (mountPrefix && (pathname === mountPrefix || pathname.startsWith(mountPrefix + "/"))) {
1460
+ pathname = pathname.slice(mountPrefix.length) || "/";
1461
+ }
1462
+ return normalizeRoutePath(pathname);
1463
+ }
1464
+ function inventoryRouteUrl(expectedPath) {
1465
+ const base = new URL(targetUrl);
1466
+ const route = new URL(expectedPath, base.origin);
1467
+ const mountPrefix = previewMountPrefix(base.pathname);
1468
+ base.pathname = mountPrefix ? joinMountedRoutePath(mountPrefix, route.pathname) : route.pathname;
1469
+ base.search = route.search;
1470
+ base.hash = route.hash;
1471
+ return base.href;
1472
+ }
1473
+ function inventorySlugFromPath(path) {
1474
+ return String(path || "route").split("/").filter(Boolean).pop()?.replace(/[^a-z0-9]+/gi, "-").toLowerCase() || "route";
1475
+ }
1476
+ function inventoryCheckTimeout(check) {
1477
+ const timeout = Number(check && check.timeout_ms);
1478
+ return Number.isFinite(timeout) && timeout > 0 ? timeout : 45000;
1479
+ }
1480
+ async function collectInventoryHomeLinks(check) {
1481
+ const linkSelector = check.link_selector || "a[href]";
1482
+ const expectedPaths = (check.expected_routes || []).map((route) => route.path);
1483
+ const routePathPrefix = check.route_path_prefix || "";
1484
+ return await page.evaluate(({ linkSelector, expectedPaths, routePathPrefix, targetUrl }) => {
1485
+ const previewMountPrefix = (pathname) => {
1486
+ const value = pathname || "/";
1487
+ const apiPreview = value.match(/^(\/s\/[^/]+)(?:\/|$)/);
1488
+ if (apiPreview) return apiPreview[1];
1489
+ const internalPreview = value.match(/^(\/preview\/[^/]+\/[^/]+)(?:\/|$)/);
1490
+ return internalPreview ? internalPreview[1] : "";
1491
+ };
1492
+ const normalizeRoutePath = (path) => {
1493
+ const value = path || "/";
1494
+ if (value === "/") return "/";
1495
+ return value.replace(/\/+$/, "") || "/";
1496
+ };
1497
+ const appPathFromHref = (href) => {
1498
+ const url = new URL(href, location.href);
1499
+ const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
1500
+ let pathname = url.pathname || "/";
1501
+ if (mountPrefix && (pathname === mountPrefix || pathname.startsWith(mountPrefix + "/"))) {
1502
+ pathname = pathname.slice(mountPrefix.length) || "/";
1503
+ }
1504
+ return normalizeRoutePath(pathname);
1505
+ };
1506
+ const expectedSet = new Set(expectedPaths);
1507
+ const links = Array.from(document.querySelectorAll(linkSelector)).map((anchor) => {
1508
+ const href = anchor.getAttribute("href") || "";
1509
+ const appPath = appPathFromHref(href || anchor.href || location.href);
1510
+ return {
1511
+ text: (anchor.textContent || "").replace(/\s+/g, " ").trim().slice(0, 240),
1512
+ href,
1513
+ pathname: new URL(href || anchor.href || location.href, location.href).pathname,
1514
+ app_path: appPath,
1515
+ };
1516
+ });
1517
+ return links.filter((link) => (
1518
+ routePathPrefix ? link.app_path.startsWith(routePathPrefix) : expectedSet.has(link.app_path)
1519
+ ));
1520
+ }, { linkSelector, expectedPaths, routePathPrefix, targetUrl });
1521
+ }
1522
+ async function waitForInventoryRouteHealth(check, expectedPath) {
1523
+ const timeout = inventoryCheckTimeout(check);
1524
+ await page.waitForURL((url) => inventoryAppPathFromUrl(url.href) === normalizeRoutePath(expectedPath), { timeout: Math.min(timeout, 20000) });
1525
+ if (check.route_ready_selector) {
1526
+ await page.waitForSelector(check.route_ready_selector, { state: "visible", timeout });
1527
+ return;
1528
+ }
1529
+ await page.waitForFunction(({ expectedPath, sourceSelector, routeReadyText, routeReadyPattern, routeReadyFlags, targetUrl }) => {
1530
+ const previewMountPrefix = (pathname) => {
1531
+ const value = pathname || "/";
1532
+ const apiPreview = value.match(/^(\/s\/[^/]+)(?:\/|$)/);
1533
+ if (apiPreview) return apiPreview[1];
1534
+ const internalPreview = value.match(/^(\/preview\/[^/]+\/[^/]+)(?:\/|$)/);
1535
+ return internalPreview ? internalPreview[1] : "";
1536
+ };
1537
+ const normalizeRoutePath = (path) => {
1538
+ const value = path || "/";
1539
+ if (value === "/") return "/";
1540
+ return value.replace(/\/+$/, "") || "/";
1541
+ };
1542
+ const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
1543
+ let pathname = location.pathname || "/";
1544
+ if (mountPrefix && (pathname === mountPrefix || pathname.startsWith(mountPrefix + "/"))) {
1545
+ pathname = pathname.slice(mountPrefix.length) || "/";
1546
+ }
1547
+ if (normalizeRoutePath(pathname) !== normalizeRoutePath(expectedPath)) return false;
1548
+ if (sourceSelector && document.querySelector(sourceSelector)) return false;
1549
+ const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim();
1550
+ if (routeReadyPattern) {
1551
+ try {
1552
+ if (!new RegExp(routeReadyPattern, routeReadyFlags || "").test(text)) return false;
1553
+ } catch {
1554
+ return false;
1555
+ }
1556
+ }
1557
+ if (routeReadyText && !text.includes(routeReadyText)) return false;
1558
+ const loadingOnly = /^loading\.?$/i.test(text) || (/^loading/i.test(text) && text.length < 40);
1559
+ if (document.querySelectorAll("canvas").length > 0) return true;
1560
+ if (document.querySelectorAll("button").length > 0 && !loadingOnly) return true;
1561
+ return text.length > 30 && !loadingOnly && !/not found|cannot get/i.test(text);
1562
+ }, {
1563
+ expectedPath,
1564
+ sourceSelector: check.source_selector || "",
1565
+ routeReadyText: check.route_ready_text || "",
1566
+ routeReadyPattern: check.route_ready_pattern || "",
1567
+ routeReadyFlags: check.route_ready_flags || "",
1568
+ targetUrl,
1569
+ }, { timeout });
1570
+ }
1571
+ async function collectInventoryRouteSnapshot(expectedRoute, phase, check, error) {
1572
+ return await page.evaluate(({ expectedRoute, phase, sourceSelector, error, targetUrl }) => {
1573
+ const previewMountPrefix = (pathname) => {
1574
+ const value = pathname || "/";
1575
+ const apiPreview = value.match(/^(\/s\/[^/]+)(?:\/|$)/);
1576
+ if (apiPreview) return apiPreview[1];
1577
+ const internalPreview = value.match(/^(\/preview\/[^/]+\/[^/]+)(?:\/|$)/);
1578
+ return internalPreview ? internalPreview[1] : "";
1579
+ };
1580
+ const normalizeRoutePath = (path) => {
1581
+ const value = path || "/";
1582
+ if (value === "/") return "/";
1583
+ return value.replace(/\/+$/, "") || "/";
1584
+ };
1585
+ const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
1586
+ let pathname = location.pathname || "/";
1587
+ if (mountPrefix && (pathname === mountPrefix || pathname.startsWith(mountPrefix + "/"))) {
1588
+ pathname = pathname.slice(mountPrefix.length) || "/";
1589
+ }
1590
+ const sourceCount = sourceSelector ? document.querySelectorAll(sourceSelector).length : 0;
1591
+ const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim();
1592
+ return {
1593
+ phase,
1594
+ name: expectedRoute.name || null,
1595
+ path: expectedRoute.path,
1596
+ loaded: !error,
1597
+ error: error || null,
1598
+ actual_path: location.pathname,
1599
+ actual_app_path: normalizeRoutePath(pathname),
1600
+ source_selector: sourceSelector || null,
1601
+ source_count: sourceCount,
1602
+ source_visible: sourceCount > 0,
1603
+ title: document.title,
1604
+ body_text_sample: text.slice(0, 900),
1605
+ canvas_count: document.querySelectorAll("canvas").length,
1606
+ button_texts: Array.from(document.querySelectorAll("button")).slice(0, 12).map((button) => (
1607
+ (button.textContent || "").replace(/\s+/g, " ").trim()
1608
+ )),
1609
+ heading_texts: Array.from(document.querySelectorAll("h1,h2,h3")).slice(0, 8).map((heading) => (
1610
+ (heading.textContent || "").replace(/\s+/g, " ").trim()
1611
+ )),
1612
+ };
1613
+ }, { expectedRoute, phase, sourceSelector: check.source_selector || "", error: error || "", targetUrl });
1614
+ }
1615
+ async function findInventoryLinkIndex(check, expectedPath) {
1616
+ const locator = page.locator(check.link_selector || "a[href]");
1617
+ const count = await locator.count();
1618
+ for (let index = 0; index < count; index += 1) {
1619
+ const appPath = await locator.nth(index).evaluate((anchor, targetUrl) => {
1620
+ const previewMountPrefix = (pathname) => {
1621
+ const value = pathname || "/";
1622
+ const apiPreview = value.match(/^(\/s\/[^/]+)(?:\/|$)/);
1623
+ if (apiPreview) return apiPreview[1];
1624
+ const internalPreview = value.match(/^(\/preview\/[^/]+\/[^/]+)(?:\/|$)/);
1625
+ return internalPreview ? internalPreview[1] : "";
1626
+ };
1627
+ const normalizeRoutePath = (path) => {
1628
+ const value = path || "/";
1629
+ if (value === "/") return "/";
1630
+ return value.replace(/\/+$/, "") || "/";
1631
+ };
1632
+ const href = anchor.getAttribute("href") || anchor.href || location.href;
1633
+ const url = new URL(href, location.href);
1634
+ const mountPrefix = previewMountPrefix(new URL(targetUrl).pathname);
1635
+ let pathname = url.pathname || "/";
1636
+ if (mountPrefix && (pathname === mountPrefix || pathname.startsWith(mountPrefix + "/"))) {
1637
+ pathname = pathname.slice(mountPrefix.length) || "/";
1638
+ }
1639
+ return normalizeRoutePath(pathname);
1640
+ }, targetUrl).catch(() => "");
1641
+ if (appPath === normalizeRoutePath(expectedPath)) return index;
1642
+ }
1643
+ return -1;
1644
+ }
1645
+ async function collectRouteInventory(check, viewport) {
1646
+ const expectedRoutes = check.expected_routes || [];
1647
+ const expectedPaths = expectedRoutes.map((route) => normalizeRoutePath(route.path));
1648
+ const expectedSet = new Set(expectedPaths);
1649
+ const failures = [];
1650
+ const homeLinks = await collectInventoryHomeLinks(check);
1651
+ const homeLinkPaths = homeLinks.map((link) => link.app_path);
1652
+ const uniqueHomeLinkPaths = Array.from(new Set(homeLinkPaths));
1653
+ const duplicateHomeLinkPaths = homeLinkPaths.filter((path, index) => homeLinkPaths.indexOf(path) !== index);
1654
+ if (check.require_unique_routes !== false && duplicateHomeLinkPaths.length) {
1655
+ failures.push({ code: "duplicate_source_links", paths: Array.from(new Set(duplicateHomeLinkPaths)) });
1656
+ }
1657
+ for (const route of expectedRoutes) {
1658
+ if (!homeLinkPaths.includes(normalizeRoutePath(route.path))) {
1659
+ failures.push({ code: "expected_route_missing_from_source", name: route.name || null, path: route.path });
1660
+ }
1661
+ }
1662
+ const unexpectedRoutes = uniqueHomeLinkPaths.filter((path) => !expectedSet.has(path));
1663
+ if (!check.allow_unexpected_routes && unexpectedRoutes.length) {
1664
+ failures.push({ code: "unexpected_source_links", paths: unexpectedRoutes });
1665
+ }
1666
+ if (!check.allow_unexpected_routes && uniqueHomeLinkPaths.length !== expectedRoutes.length) {
1667
+ failures.push({ code: "source_link_count_mismatch", expected: expectedRoutes.length, actual_unique: uniqueHomeLinkPaths.length, actual_total: homeLinkPaths.length });
1668
+ }
1669
+
1670
+ const directRoutes = [];
1671
+ if (check.run_direct_routes !== false) {
1672
+ for (const expectedRoute of expectedRoutes) {
1673
+ let error = "";
1674
+ try {
1675
+ await page.goto(inventoryRouteUrl(expectedRoute.path), { waitUntil: "domcontentloaded", timeout: 90000 });
1676
+ await waitForInventoryRouteHealth(check, expectedRoute.path);
1677
+ } catch (caught) {
1678
+ error = String(caught && caught.message ? caught.message : caught).slice(0, 1000);
1679
+ }
1680
+ const snapshot = await collectInventoryRouteSnapshot(expectedRoute, "direct", check, error);
1681
+ directRoutes.push(snapshot);
1682
+ if (check.save_route_screenshots) {
1683
+ await saveScreenshot(profileSlug + "-direct-" + inventorySlugFromPath(expectedRoute.path)).catch(() => {});
1684
+ }
1685
+ if (error) failures.push({ code: "direct_route_unhealthy", name: expectedRoute.name || null, path: expectedRoute.path, error });
1686
+ 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 });
1687
+ else if (check.source_selector && snapshot.source_visible) failures.push({ code: "direct_route_kept_source_surface", name: expectedRoute.name || null, path: expectedRoute.path, source_selector: check.source_selector });
1688
+ }
1689
+ }
1690
+
1691
+ const clickthroughs = [];
1692
+ if (check.run_clickthroughs !== false) {
1693
+ for (const expectedRoute of expectedRoutes) {
1694
+ const result = { name: expectedRoute.name || null, path: expectedRoute.path, clicked: false, snapshot: null, error: null };
1695
+ try {
1696
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 90000 });
1697
+ if (profile.target.wait_for_selector) {
1698
+ await page.waitForSelector(profile.target.wait_for_selector, { state: "visible", timeout: 15000 }).catch(() => {});
1699
+ }
1700
+ const index = await findInventoryLinkIndex(check, expectedRoute.path);
1701
+ if (index < 0) {
1702
+ result.error = "source_link_not_found";
1703
+ failures.push({ code: "source_link_clickthrough_missing", name: expectedRoute.name || null, path: expectedRoute.path });
1704
+ } else {
1705
+ const locator = page.locator(check.link_selector || "a[href]").nth(index);
1706
+ await locator.scrollIntoViewIfNeeded();
1707
+ await locator.click({ timeout: inventoryCheckTimeout(check), noWaitAfter: true });
1708
+ result.clicked = true;
1709
+ await waitForInventoryRouteHealth(check, expectedRoute.path);
1710
+ }
1711
+ } catch (caught) {
1712
+ result.error = String(caught && caught.message ? caught.message : caught).slice(0, 1000);
1713
+ }
1714
+ result.snapshot = await collectInventoryRouteSnapshot(expectedRoute, "clickthrough", check, result.error || "");
1715
+ clickthroughs.push(result);
1716
+ if (check.save_route_screenshots) {
1717
+ await saveScreenshot(profileSlug + "-click-" + inventorySlugFromPath(expectedRoute.path)).catch(() => {});
1718
+ }
1719
+ 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 });
1720
+ 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 });
1721
+ else if (check.source_selector && result.snapshot.source_visible) failures.push({ code: "source_link_clickthrough_kept_source_surface", name: expectedRoute.name || null, path: expectedRoute.path, source_selector: check.source_selector });
1722
+ }
1723
+ }
1724
+
1725
+ return {
1726
+ version: "riddle-proof.route-inventory.v1",
1727
+ viewport: viewport.name,
1728
+ expected_routes: expectedRoutes,
1729
+ link_selector: check.link_selector || "a[href]",
1730
+ source_selector: check.source_selector || null,
1731
+ home_game_link_count: homeLinkPaths.length,
1732
+ home_unique_game_link_count: uniqueHomeLinkPaths.length,
1733
+ home_links: homeLinks,
1734
+ direct_routes: directRoutes,
1735
+ clickthroughs,
1736
+ failures,
1737
+ };
1738
+ }
1340
1739
  async function captureViewport(viewport) {
1341
1740
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
1342
1741
  let httpStatus = null;
@@ -1459,6 +1858,31 @@ async function captureViewport(viewport) {
1459
1858
  } catch (error) {
1460
1859
  pageErrors.push({ message: "saveScreenshot failed: " + String(error && error.message ? error.message : error).slice(0, 500) });
1461
1860
  }
1861
+ let routeInventory;
1862
+ const routeInventoryCheck = (profile.checks || []).find((check) => check.type === "route_inventory");
1863
+ const firstViewportName = (profile.target.viewports || [])[0] && (profile.target.viewports || [])[0].name;
1864
+ if (routeInventoryCheck && (!firstViewportName || viewport.name === firstViewportName)) {
1865
+ try {
1866
+ routeInventory = await collectRouteInventory(routeInventoryCheck, viewport);
1867
+ } catch (error) {
1868
+ routeInventory = {
1869
+ version: "riddle-proof.route-inventory.v1",
1870
+ viewport: viewport.name,
1871
+ expected_routes: routeInventoryCheck.expected_routes || [],
1872
+ link_selector: routeInventoryCheck.link_selector || "a[href]",
1873
+ source_selector: routeInventoryCheck.source_selector || null,
1874
+ home_game_link_count: 0,
1875
+ home_unique_game_link_count: 0,
1876
+ home_links: [],
1877
+ direct_routes: [],
1878
+ clickthroughs: [],
1879
+ failures: [{
1880
+ code: "route_inventory_capture_failed",
1881
+ error: String(error && error.message ? error.message : error).slice(0, 1000),
1882
+ }],
1883
+ };
1884
+ }
1885
+ }
1462
1886
  const expectedPath = profile.checks.find((check) => check.type === "route_loaded" && check.expected_path)?.expected_path || new URL(targetUrl).pathname || "/";
1463
1887
  return {
1464
1888
  name: viewport.name,
@@ -1483,6 +1907,7 @@ async function captureViewport(viewport) {
1483
1907
  overflow_offenders: dom.overflow_offenders || [],
1484
1908
  selectors,
1485
1909
  text_matches,
1910
+ route_inventory: routeInventory,
1486
1911
  setup_action_results: setupActionResults,
1487
1912
  screenshot_label: screenshotLabel,
1488
1913
  navigation_error: navigationError,
@@ -1515,6 +1940,16 @@ function buildProfileEvidence(currentViewports) {
1515
1940
  overflow_px: currentViewports.map((viewport) => viewport.overflow_px),
1516
1941
  bounds_overflow_px: currentViewports.map((viewport) => viewport.bounds_overflow_px),
1517
1942
  overflow_offender_counts: currentViewports.map((viewport) => (viewport.overflow_offenders || []).length),
1943
+ route_inventory: currentViewports
1944
+ .filter((viewport) => viewport.route_inventory)
1945
+ .map((viewport) => ({
1946
+ viewport: viewport.name,
1947
+ expected_count: (viewport.route_inventory.expected_routes || []).length,
1948
+ home_unique_game_link_count: viewport.route_inventory.home_unique_game_link_count,
1949
+ direct_route_count: (viewport.route_inventory.direct_routes || []).length,
1950
+ clickthrough_count: (viewport.route_inventory.clickthroughs || []).length,
1951
+ failure_count: (viewport.route_inventory.failures || []).length,
1952
+ })),
1518
1953
  network_mock_count: (profile.target.network_mocks || []).length,
1519
1954
  network_mock_hit_count: networkMockEvents.filter((event) => event.ok !== false).length,
1520
1955
  },