@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 +27 -0
- package/dist/{chunk-FDNIOGQD.js → chunk-COUZXKGM.js} +437 -2
- package/dist/cli.cjs +437 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +437 -2
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/profile.cjs +437 -2
- package/dist/profile.d.cts +21 -2
- package/dist/profile.d.ts +21 -2
- package/dist/profile.js +1 -1
- package/examples/profiles/route-inventory-basic.json +29 -0
- package/package.json +1 -1
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
|
},
|