@riddledc/riddle-proof 0.7.43 → 0.7.45

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
@@ -148,6 +148,7 @@ or as a stronger proof base before a change loop.
148
148
  { "type": "selector_absent", "selector": "[data-testid='loading-spinner']" },
149
149
  { "type": "selector_count_equals", "selector": "[data-testid='pricing-card']", "expected_count": 3 },
150
150
  { "type": "text_visible", "text": "Start building" },
151
+ { "type": "text_visible", "text": "Compare plans", "viewports": ["desktop"] },
151
152
  { "type": "no_mobile_horizontal_overflow" },
152
153
  { "type": "no_fatal_console_errors" }
153
154
  ],
@@ -179,6 +180,11 @@ The package includes a generic starter profile at
179
180
  profile directory and replace the selector/text checks with app-specific
180
181
  invariants.
181
182
 
183
+ Checks normally apply to every captured viewport. Add `viewports` (or
184
+ `viewport_names`) to a check when responsive UI intentionally exposes an
185
+ invariant only on named viewports, such as desktop-only helper copy while phone
186
+ layouts keep the same route, link, and overflow contracts.
187
+
182
188
  `target.network_mocks` is optional. The Riddle runner registers these mocks
183
189
  before navigation, records each hit, and adds an implicit
184
190
  `network_mocks_succeeded` check when mocks are present. A mock supports
@@ -217,6 +223,13 @@ cycle instead of reusing the final response, for example to repeat a fail-then-
217
223
  success pair across multiple viewports. Repeated sequences also record
218
224
  `sequence_cycle: true` after the first cycle.
219
225
 
226
+ Set `capture_request_body: true` to include compact request-body evidence on
227
+ mock hits. Add `request_body_contains` or `request_body_patterns` when the
228
+ request body is part of the contract, such as proving that a save request
229
+ references the build ID returned by a prior mocked build response. Body
230
+ assertions use the full request body for matching and store only length plus a
231
+ compact sample in the proof evidence.
232
+
220
233
  `target.setup_actions` is optional. Use it when the meaningful proof surface
221
234
  appears only after a picker, tab, login stub, storage seed, form fill,
222
235
  transport control, or other bounded interaction. Supported setup actions are
@@ -253,6 +253,15 @@ function normalizeNetworkMock(input, index) {
253
253
  const payload = normalizeNetworkMockResponsePayload(input, `target.network_mocks[${index}]`);
254
254
  const responsesInput = input.responses ?? input.sequence;
255
255
  const responses = normalizeNetworkMockResponses(responsesInput, index, payload);
256
+ const requestBodyContains = normalizeStringList(
257
+ input.request_body_contains ?? input.requestBodyContains ?? input.body_contains ?? input.bodyContains,
258
+ `target.network_mocks[${index}].request_body_contains`
259
+ );
260
+ const requestBodyPatterns = normalizeStringList(
261
+ input.request_body_patterns ?? input.requestBodyPatterns ?? input.body_patterns ?? input.bodyPatterns,
262
+ `target.network_mocks[${index}].request_body_patterns`
263
+ );
264
+ validateRegexPatterns(requestBodyPatterns, `target.network_mocks[${index}].request_body_patterns`);
256
265
  const requiredHitCount = numberValue(
257
266
  input.required_hit_count ?? input.requiredHitCount ?? input.required_hits ?? input.requiredHits ?? input.min_hits ?? input.minHits
258
267
  );
@@ -267,7 +276,10 @@ function normalizeNetworkMock(input, index) {
267
276
  responses,
268
277
  repeat_responses: input.repeat_responses === true || input.repeatResponses === true || input.cycle_responses === true || input.cycleResponses === true,
269
278
  required_hit_count: requiredHitCount,
270
- required: input.required === false ? false : true
279
+ required: input.required === false ? false : true,
280
+ capture_request_body: input.capture_request_body === true || input.captureRequestBody === true || Boolean(requestBodyContains?.length) || Boolean(requestBodyPatterns?.length),
281
+ request_body_contains: requestBodyContains,
282
+ request_body_patterns: requestBodyPatterns
271
283
  };
272
284
  }
273
285
  function normalizeNetworkMockResponsePayload(input, label, defaults = {}) {
@@ -350,6 +362,16 @@ function normalizeStringList(value, label) {
350
362
  if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
351
363
  return values;
352
364
  }
365
+ function validateRegexPatterns(patterns, label) {
366
+ for (const pattern of patterns || []) {
367
+ try {
368
+ new RegExp(pattern);
369
+ } catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ throw new Error(`${label} contains invalid regex ${JSON.stringify(pattern)}: ${message}`);
372
+ }
373
+ }
374
+ }
353
375
  function normalizeCheck(input, index) {
354
376
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
355
377
  const type = stringValue(input.type);
@@ -402,6 +424,7 @@ function normalizeCheck(input, index) {
402
424
  text: stringValue(input.text),
403
425
  pattern: stringValue(input.pattern),
404
426
  flags: stringValue(input.flags),
427
+ viewports: normalizeStringList(input.viewports ?? input.viewport_names ?? input.viewportNames, `checks[${index}] viewports`),
405
428
  allowed_console_texts: normalizeStringList(input.allowed_console_texts ?? input.allowedConsoleTexts ?? input.allow_console_texts ?? input.allowConsoleTexts, `checks[${index}] allowed_console_texts`),
406
429
  allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
407
430
  allowed_page_error_texts: normalizeStringList(input.allowed_page_error_texts ?? input.allowedPageErrorTexts ?? input.allow_page_error_texts ?? input.allowPageErrorTexts, `checks[${index}] allowed_page_error_texts`),
@@ -640,15 +663,22 @@ function successfulRoute(route, targetUrl) {
640
663
  const matched = route.matched || routePathMatches(route.observed, route.expected_path, targetUrl);
641
664
  return matched && !route.error && (route.http_status === null || route.http_status === void 0 || route.http_status < 400);
642
665
  }
666
+ function viewportsForCheck(check, viewports) {
667
+ if (!check.viewports?.length) return viewports;
668
+ const names = new Set(check.viewports);
669
+ return viewports.filter((viewport) => names.has(viewport.name));
670
+ }
643
671
  function assessCheckFromEvidence(check, evidence) {
644
- const viewports = evidence.viewports || [];
672
+ const viewports = viewportsForCheck(check, evidence.viewports || []);
645
673
  if (!viewports.length) {
646
674
  return {
647
675
  type: check.type,
648
676
  label: checkLabel(check),
649
677
  status: "failed",
650
- evidence: {},
651
- message: "No viewport evidence was captured."
678
+ evidence: {
679
+ expected_viewports: check.viewports || []
680
+ },
681
+ message: check.viewports?.length ? `No matching viewport evidence was captured for ${check.viewports.join(", ")}.` : "No viewport evidence was captured."
652
682
  };
653
683
  }
654
684
  if (check.type === "route_loaded") {
@@ -1012,6 +1042,17 @@ function assessNetworkMocksFromEvidence(profile, evidence) {
1012
1042
  reason: event.reason ?? event.error ?? "mock_failed"
1013
1043
  });
1014
1044
  }
1045
+ if (event.request_body_matches === false) {
1046
+ failed.push({
1047
+ label: event.label ?? null,
1048
+ url: event.url ?? null,
1049
+ method: event.method ?? null,
1050
+ reason: "request_body_mismatch",
1051
+ request_body_failures: event.request_body_failures ?? [],
1052
+ request_body_length: event.request_body_length ?? null,
1053
+ request_body_sample: event.request_body_sample ?? null
1054
+ });
1055
+ }
1015
1056
  }
1016
1057
  return {
1017
1058
  type: "network_mocks_succeeded",
@@ -1253,6 +1294,11 @@ function frameTextSample(frame) {
1253
1294
  frame && frame.text,
1254
1295
  ].map((part) => String(part || "")).filter(Boolean).join(" ");
1255
1296
  }
1297
+ function viewportsForCheck(check, viewports) {
1298
+ if (!Array.isArray(check.viewports) || !check.viewports.length) return viewports;
1299
+ const names = new Set(check.viewports);
1300
+ return viewports.filter((viewport) => names.has(viewport.name));
1301
+ }
1256
1302
  function summarizeRouteInventory(viewport, inventory) {
1257
1303
  const directRoutes = Array.isArray(inventory.direct_routes) ? inventory.direct_routes : [];
1258
1304
  const clickthroughs = Array.isArray(inventory.clickthroughs) ? inventory.clickthroughs : [];
@@ -1379,6 +1425,17 @@ function assessProfile(profile, evidence) {
1379
1425
  reason: event.reason || event.error || "mock_failed",
1380
1426
  });
1381
1427
  }
1428
+ if (event && event.request_body_matches === false) {
1429
+ failed.push({
1430
+ label: event.label || null,
1431
+ url: event.url || null,
1432
+ method: event.method || null,
1433
+ reason: "request_body_mismatch",
1434
+ request_body_failures: event.request_body_failures || [],
1435
+ request_body_length: event.request_body_length || null,
1436
+ request_body_sample: event.request_body_sample || null,
1437
+ });
1438
+ }
1382
1439
  }
1383
1440
  const hitsByLabel = {};
1384
1441
  const requiredHitsByLabel = {};
@@ -1443,9 +1500,22 @@ function assessProfile(profile, evidence) {
1443
1500
  });
1444
1501
  }
1445
1502
  for (const check of profile.checks || []) {
1503
+ const checkViewports = viewportsForCheck(check, viewports);
1504
+ if (!checkViewports.length) {
1505
+ checks.push({
1506
+ type: check.type,
1507
+ label: check.label || check.type,
1508
+ status: "failed",
1509
+ evidence: { expected_viewports: check.viewports || [] },
1510
+ message: Array.isArray(check.viewports) && check.viewports.length
1511
+ ? "No matching viewport evidence was captured for " + check.viewports.join(", ") + "."
1512
+ : "No viewport evidence was captured.",
1513
+ });
1514
+ continue;
1515
+ }
1446
1516
  if (check.type === "route_loaded") {
1447
1517
  const expectedPath = check.expected_path || new URL(evidence.target_url).pathname || "/";
1448
- const failed = viewports.filter((viewport) => {
1518
+ const failed = checkViewports.filter((viewport) => {
1449
1519
  const route = { ...(viewport.route || {}), expected_path: expectedPath };
1450
1520
  route.matched = routePathMatches(route.observed, expectedPath, evidence.target_url) || route.matched;
1451
1521
  return !routeOk(route, evidence.target_url);
@@ -1456,8 +1526,8 @@ function assessProfile(profile, evidence) {
1456
1526
  status: failed.length ? "failed" : "passed",
1457
1527
  evidence: {
1458
1528
  expected_path: expectedPath,
1459
- observed_paths: viewports.map((viewport) => viewport.route && viewport.route.observed),
1460
- http_statuses: viewports.map((viewport) => viewport.route ? viewport.route.http_status ?? null : null),
1529
+ observed_paths: checkViewports.map((viewport) => viewport.route && viewport.route.observed),
1530
+ http_statuses: checkViewports.map((viewport) => viewport.route ? viewport.route.http_status ?? null : null),
1461
1531
  },
1462
1532
  message: failed.length ? "Route did not load as " + expectedPath + " in " + failed.length + " viewport(s)." : undefined,
1463
1533
  });
@@ -1465,24 +1535,24 @@ function assessProfile(profile, evidence) {
1465
1535
  }
1466
1536
  if (check.type === "selector_visible") {
1467
1537
  const selector = check.selector || "";
1468
- const failed = viewports.filter((viewport) => !viewport.selectors || !viewport.selectors[selector] || viewport.selectors[selector].visible_count < 1);
1538
+ const failed = checkViewports.filter((viewport) => !viewport.selectors || !viewport.selectors[selector] || viewport.selectors[selector].visible_count < 1);
1469
1539
  checks.push({
1470
1540
  type: check.type,
1471
1541
  label: check.label || check.type,
1472
1542
  status: failed.length ? "failed" : "passed",
1473
- evidence: { selector, visible_counts: viewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].visible_count : 0) },
1543
+ evidence: { selector, visible_counts: checkViewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].visible_count : 0) },
1474
1544
  message: failed.length ? "Selector " + selector + " was not visible in " + failed.length + " viewport(s)." : undefined,
1475
1545
  });
1476
1546
  continue;
1477
1547
  }
1478
1548
  if (check.type === "selector_absent") {
1479
1549
  const selector = check.selector || "";
1480
- const failed = viewports.filter((viewport) => viewport.selectors && viewport.selectors[selector] && viewport.selectors[selector].count > 0);
1550
+ const failed = checkViewports.filter((viewport) => viewport.selectors && viewport.selectors[selector] && viewport.selectors[selector].count > 0);
1481
1551
  checks.push({
1482
1552
  type: check.type,
1483
1553
  label: check.label || check.type,
1484
1554
  status: failed.length ? "failed" : "passed",
1485
- evidence: { selector, counts: viewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1555
+ evidence: { selector, counts: checkViewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1486
1556
  message: failed.length ? "Selector " + selector + " was present in " + failed.length + " viewport(s)." : undefined,
1487
1557
  });
1488
1558
  continue;
@@ -1490,12 +1560,12 @@ function assessProfile(profile, evidence) {
1490
1560
  if (check.type === "selector_count_at_least") {
1491
1561
  const selector = check.selector || "";
1492
1562
  const minCount = check.min_count == null ? 1 : check.min_count;
1493
- const failed = viewports.filter((viewport) => !viewport.selectors || !viewport.selectors[selector] || viewport.selectors[selector].count < minCount);
1563
+ const failed = checkViewports.filter((viewport) => !viewport.selectors || !viewport.selectors[selector] || viewport.selectors[selector].count < minCount);
1494
1564
  checks.push({
1495
1565
  type: check.type,
1496
1566
  label: check.label || check.type,
1497
1567
  status: failed.length ? "failed" : "passed",
1498
- evidence: { selector, min_count: minCount, counts: viewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1568
+ evidence: { selector, min_count: minCount, counts: checkViewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1499
1569
  message: failed.length ? "Selector " + selector + " count was below " + minCount + " in " + failed.length + " viewport(s)." : undefined,
1500
1570
  });
1501
1571
  continue;
@@ -1503,12 +1573,12 @@ function assessProfile(profile, evidence) {
1503
1573
  if (check.type === "selector_count_equals" || check.type === "selector_count_equal" || check.type === "selector_count_eq") {
1504
1574
  const selector = check.selector || "";
1505
1575
  const expectedCount = check.expected_count == null ? 0 : check.expected_count;
1506
- const failed = viewports.filter((viewport) => (viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) !== expectedCount);
1576
+ const failed = checkViewports.filter((viewport) => (viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) !== expectedCount);
1507
1577
  checks.push({
1508
1578
  type: check.type,
1509
1579
  label: check.label || check.type,
1510
1580
  status: failed.length ? "failed" : "passed",
1511
- evidence: { selector, expected_count: expectedCount, counts: viewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1581
+ evidence: { selector, expected_count: expectedCount, counts: checkViewports.map((viewport) => viewport.selectors && viewport.selectors[selector] ? viewport.selectors[selector].count : 0) },
1512
1582
  message: failed.length ? "Selector " + selector + " count did not equal " + expectedCount + " in " + failed.length + " viewport(s)." : undefined,
1513
1583
  });
1514
1584
  continue;
@@ -1516,7 +1586,7 @@ function assessProfile(profile, evidence) {
1516
1586
  if (check.type === "selector_text_order") {
1517
1587
  const selector = check.selector || "";
1518
1588
  const expectedTexts = check.expected_texts || [];
1519
- const results = viewports.map((viewport) => {
1589
+ const results = checkViewports.map((viewport) => {
1520
1590
  const texts = textSequenceForCheck(viewport, check);
1521
1591
  const match = textOrderMatch(texts, expectedTexts);
1522
1592
  return {
@@ -1538,7 +1608,7 @@ function assessProfile(profile, evidence) {
1538
1608
  }
1539
1609
  if (check.type === "frame_text_visible") {
1540
1610
  const selector = check.selector || "";
1541
- const results = viewports.map((viewport) => {
1611
+ const results = checkViewports.map((viewport) => {
1542
1612
  const frames = frameEvidenceForSelector(viewport, selector);
1543
1613
  const matches = frames
1544
1614
  .map((frame, index) => ({ index, frame, matched: textMatches(frameTextSample(frame), check) }))
@@ -1565,7 +1635,7 @@ function assessProfile(profile, evidence) {
1565
1635
  if (check.type === "frame_no_horizontal_overflow") {
1566
1636
  const selector = check.selector || "";
1567
1637
  const maxOverflow = check.max_overflow_px == null ? 4 : check.max_overflow_px;
1568
- const results = viewports.map((viewport) => {
1638
+ const results = checkViewports.map((viewport) => {
1569
1639
  const frames = frameEvidenceForSelector(viewport, selector);
1570
1640
  const frameOverflows = frames.map((frame, index) => ({
1571
1641
  index,
@@ -1595,7 +1665,7 @@ function assessProfile(profile, evidence) {
1595
1665
  if (check.type === "text_visible" || check.type === "text_absent") {
1596
1666
  const key = check.pattern ? "pattern:" + check.pattern + "/" + (check.flags || "") : "text:" + (check.text || "");
1597
1667
  const expectedVisible = check.type === "text_visible";
1598
- const matches = viewports.map((viewport) => viewport.text_matches && typeof viewport.text_matches[key] === "boolean" ? viewport.text_matches[key] : textMatches(viewport.body_text_sample || "", check));
1668
+ const matches = checkViewports.map((viewport) => viewport.text_matches && typeof viewport.text_matches[key] === "boolean" ? viewport.text_matches[key] : textMatches(viewport.body_text_sample || "", check));
1599
1669
  const failed = matches.filter((matched) => matched !== expectedVisible).length;
1600
1670
  checks.push({
1601
1671
  type: check.type,
@@ -1607,7 +1677,7 @@ function assessProfile(profile, evidence) {
1607
1677
  continue;
1608
1678
  }
1609
1679
  if (check.type === "route_inventory") {
1610
- const inventories = viewports
1680
+ const inventories = checkViewports
1611
1681
  .map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
1612
1682
  .filter((item) => item.inventory && typeof item.inventory === "object");
1613
1683
  if (!inventories.length) {
@@ -1659,7 +1729,7 @@ function assessProfile(profile, evidence) {
1659
1729
  }
1660
1730
  if (check.type === "no_horizontal_overflow" || check.type === "no_mobile_horizontal_overflow") {
1661
1731
  const maxOverflow = check.max_overflow_px == null ? 4 : check.max_overflow_px;
1662
- const applicable = check.type === "no_mobile_horizontal_overflow" ? viewports.filter((viewport) => viewport.width <= 820) : viewports;
1732
+ const applicable = check.type === "no_mobile_horizontal_overflow" ? checkViewports.filter((viewport) => viewport.width <= 820) : checkViewports;
1663
1733
  if (!applicable.length) {
1664
1734
  checks.push({
1665
1735
  type: check.type,
@@ -1814,6 +1884,52 @@ function compactSetupResultText(value) {
1814
1884
  if (text.length <= 500) return text;
1815
1885
  return text.slice(0, 500) + "... (" + text.length + " chars)";
1816
1886
  }
1887
+ function compactNetworkMockRequestBody(value) {
1888
+ const text = String(value || "").replace(/\s+/g, " ").trim();
1889
+ if (text.length <= 1000) return text;
1890
+ return text.slice(0, 1000) + "... (" + text.length + " chars)";
1891
+ }
1892
+ function networkMockStringList(value) {
1893
+ return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
1894
+ }
1895
+ function networkMockShouldCaptureRequestBody(mock) {
1896
+ return mock && (
1897
+ mock.capture_request_body === true
1898
+ || networkMockStringList(mock.request_body_contains).length > 0
1899
+ || networkMockStringList(mock.request_body_patterns).length > 0
1900
+ );
1901
+ }
1902
+ function networkMockRequestBodyFailures(body, mock) {
1903
+ const failures = [];
1904
+ const rawBody = String(body || "");
1905
+ const compactBody = compactNetworkMockRequestBody(rawBody);
1906
+ for (const expected of networkMockStringList(mock.request_body_contains)) {
1907
+ if (!rawBody.includes(expected) && !compactBody.includes(expected)) {
1908
+ failures.push({
1909
+ type: "request_body_missing_text",
1910
+ text: String(expected).slice(0, 200),
1911
+ });
1912
+ }
1913
+ }
1914
+ for (const pattern of networkMockStringList(mock.request_body_patterns)) {
1915
+ try {
1916
+ const regex = new RegExp(pattern);
1917
+ if (!regex.test(rawBody) && !regex.test(compactBody)) {
1918
+ failures.push({
1919
+ type: "request_body_pattern_not_matched",
1920
+ pattern: String(pattern).slice(0, 200),
1921
+ });
1922
+ }
1923
+ } catch (error) {
1924
+ failures.push({
1925
+ type: "request_body_invalid_pattern",
1926
+ pattern: String(pattern).slice(0, 200),
1927
+ error: String(error && error.message ? error.message : error).slice(0, 500),
1928
+ });
1929
+ }
1930
+ }
1931
+ return failures;
1932
+ }
1817
1933
  async function setupLocatorVisible(locator, index) {
1818
1934
  return await locator.nth(index).isVisible({ timeout: 1000 }).catch(() => false);
1819
1935
  }
@@ -1842,7 +1958,11 @@ async function registerNetworkMocks(mocks) {
1842
1958
  body = JSON.stringify(response.body_json);
1843
1959
  contentType = response.content_type || headers["content-type"] || headers["Content-Type"] || "application/json";
1844
1960
  }
1845
- networkMockEvents.push({
1961
+ const shouldCaptureRequestBody = networkMockShouldCaptureRequestBody(mock);
1962
+ const requestBody = shouldCaptureRequestBody && request.postData ? request.postData() || "" : "";
1963
+ const requestBodyFailures = shouldCaptureRequestBody ? networkMockRequestBodyFailures(requestBody, mock) : [];
1964
+ const status = response.status || mock.status || 200;
1965
+ const event = {
1846
1966
  ok: true,
1847
1967
  label: mock.label,
1848
1968
  response_label: response.label || null,
@@ -1852,10 +1972,17 @@ async function registerNetworkMocks(mocks) {
1852
1972
  sequence_cycle: responseIndex !== null && mock.repeat_responses === true && hitIndex >= responses.length,
1853
1973
  url: request.url(),
1854
1974
  method,
1855
- status: response.status || mock.status || 200,
1856
- });
1975
+ status,
1976
+ };
1977
+ if (shouldCaptureRequestBody) {
1978
+ event.request_body_matches = requestBodyFailures.length === 0;
1979
+ event.request_body_failures = requestBodyFailures;
1980
+ event.request_body_length = requestBody.length;
1981
+ event.request_body_sample = compactNetworkMockRequestBody(requestBody);
1982
+ }
1983
+ networkMockEvents.push(event);
1857
1984
  await route.fulfill({
1858
- status: response.status || mock.status || 200,
1985
+ status,
1859
1986
  headers,
1860
1987
  contentType,
1861
1988
  body,