@riddledc/riddle-proof 0.7.38 → 0.7.40

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
@@ -182,7 +182,35 @@ before navigation, records each hit, and adds an implicit
182
182
  `network_mocks_succeeded` check when mocks are present. A mock supports
183
183
  `url`/`glob`/`pattern`, optional `method`, `status`, `content_type`, `headers`,
184
184
  string `body`, JSON `json` / `body_json`, and `required: false` for
185
- best-effort mocks.
185
+ best-effort mocks. Use `responses` for retry or recovery profiles where the
186
+ same endpoint should return a sequence, such as first `503` and then `200`.
187
+ Each response accepts the same payload fields plus an optional label:
188
+
189
+ ```json
190
+ {
191
+ "label": "builder-build",
192
+ "url": "**/api/build",
193
+ "method": "POST",
194
+ "responses": [
195
+ {
196
+ "label": "first-build-fails",
197
+ "status": 503,
198
+ "json": { "error": "Synthetic build outage" }
199
+ },
200
+ {
201
+ "label": "second-build-succeeds",
202
+ "status": 200,
203
+ "json": { "previewUrl": "https://cdn.example/game/index.html" }
204
+ }
205
+ ]
206
+ }
207
+ ```
208
+
209
+ When `responses` is present, `network_mocks_succeeded` requires each configured
210
+ response to be hit at least once by default and records `hit_index`,
211
+ `response_index`, and `response_label` for each request. Set
212
+ `required_hit_count` / `min_hits` or `required: false` when a different
213
+ contract is intentional.
186
214
 
187
215
  `target.setup_actions` is optional. Use it when the meaningful proof surface
188
216
  appears only after a picker, tab, login stub, storage seed, form fill,
@@ -202,6 +230,25 @@ clears `local`, `session`, or `both` browser storage scopes, defaults to
202
230
  profile carries its own hosted Riddle worker budget; an explicit CLI `--timeout`
203
231
  still overrides the profile value for one-off runs.
204
232
 
233
+ Use `allowed_console_patterns` / `allowed_console_texts` on
234
+ `no_fatal_console_errors` when a negative-path profile intentionally triggers a
235
+ known browser console error, such as a mocked `503` that the app recovers from:
236
+
237
+ ```json
238
+ {
239
+ "type": "no_fatal_console_errors",
240
+ "allowed_console_patterns": [
241
+ "Failed to load resource: the server responded with a status of 503",
242
+ "Build failed: Error: Synthetic build outage"
243
+ ]
244
+ }
245
+ ```
246
+
247
+ Allowed console events and page errors are still counted in check evidence, but
248
+ only unallowed `error` / `assert` console events and page errors fail the check.
249
+ Use `allowed_page_error_patterns`, `allowed_console_texts`, or
250
+ `allowed_page_error_texts` for narrower matching when needed.
251
+
205
252
  Use `selector_text_order` when a table, list, or card group must show visible
206
253
  items in a specific order after setup actions such as sorting or filtering:
207
254
 
@@ -246,24 +246,57 @@ function normalizeNetworkMock(input, index) {
246
246
  if (!isRecord(input)) throw new Error(`target.network_mocks[${index}] must be an object.`);
247
247
  const url = stringValue(input.url) || stringValue(input.glob) || stringValue(input.pattern);
248
248
  if (!url) throw new Error(`target.network_mocks[${index}] requires url.`);
249
- const status = numberValue(input.status) ?? 200;
250
- if (!Number.isInteger(status) || status < 100 || status > 599) {
251
- throw new Error(`target.network_mocks[${index}].status must be an HTTP status code.`);
249
+ const payload = normalizeNetworkMockResponsePayload(input, `target.network_mocks[${index}]`);
250
+ const responsesInput = input.responses ?? input.sequence;
251
+ const responses = normalizeNetworkMockResponses(responsesInput, index, payload);
252
+ const requiredHitCount = numberValue(
253
+ input.required_hit_count ?? input.requiredHitCount ?? input.required_hits ?? input.requiredHits ?? input.min_hits ?? input.minHits
254
+ );
255
+ if (requiredHitCount !== void 0 && (!Number.isInteger(requiredHitCount) || requiredHitCount < 1)) {
256
+ throw new Error(`target.network_mocks[${index}].required_hit_count must be a positive integer.`);
252
257
  }
253
- const body = stringValue(input.body) ?? stringValue(input.body_text) ?? stringValue(input.bodyText);
254
- const hasJsonBody = Object.prototype.hasOwnProperty.call(input, "body_json") || Object.prototype.hasOwnProperty.call(input, "bodyJson") || Object.prototype.hasOwnProperty.call(input, "json");
255
258
  return {
259
+ ...payload,
256
260
  label: normalizeName(input.label || input.name, `network-mock-${index + 1}`),
257
261
  url,
258
262
  method: stringValue(input.method)?.toUpperCase(),
263
+ responses,
264
+ required_hit_count: requiredHitCount,
265
+ required: input.required === false ? false : true
266
+ };
267
+ }
268
+ function normalizeNetworkMockResponsePayload(input, label, defaults = {}) {
269
+ const status = numberValue(input.status) ?? defaults.status ?? 200;
270
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
271
+ throw new Error(`${label}.status must be an HTTP status code.`);
272
+ }
273
+ const body = stringValue(input.body) ?? stringValue(input.body_text) ?? stringValue(input.bodyText) ?? defaults.body;
274
+ const hasJsonBody = Object.prototype.hasOwnProperty.call(input, "body_json") || Object.prototype.hasOwnProperty.call(input, "bodyJson") || Object.prototype.hasOwnProperty.call(input, "json");
275
+ return {
276
+ label: stringValue(input.label) || stringValue(input.name) || defaults.label,
259
277
  status,
260
- content_type: stringValue(input.content_type) || stringValue(input.contentType),
261
- headers: stringRecord(input.headers),
278
+ content_type: stringValue(input.content_type) || stringValue(input.contentType) || defaults.content_type,
279
+ headers: stringRecord(input.headers) || defaults.headers,
262
280
  body,
263
- body_json: hasJsonBody ? toJsonValue(input.body_json ?? input.bodyJson ?? input.json) : void 0,
264
- required: input.required === false ? false : true
281
+ body_json: hasJsonBody ? toJsonValue(input.body_json ?? input.bodyJson ?? input.json) : defaults.body_json
265
282
  };
266
283
  }
284
+ function normalizeNetworkMockResponses(value, mockIndex, defaults) {
285
+ if (value === void 0) return void 0;
286
+ if (!Array.isArray(value)) throw new Error(`target.network_mocks[${mockIndex}].responses must be an array.`);
287
+ if (!value.length) throw new Error(`target.network_mocks[${mockIndex}].responses must not be empty.`);
288
+ const responseDefaults = { ...defaults, label: void 0 };
289
+ return value.map((response, responseIndex) => {
290
+ if (!isRecord(response)) {
291
+ throw new Error(`target.network_mocks[${mockIndex}].responses[${responseIndex}] must be an object.`);
292
+ }
293
+ return normalizeNetworkMockResponsePayload(
294
+ response,
295
+ `target.network_mocks[${mockIndex}].responses[${responseIndex}]`,
296
+ responseDefaults
297
+ );
298
+ });
299
+ }
267
300
  function normalizeNetworkMocks(value) {
268
301
  if (value === void 0) return void 0;
269
302
  if (!Array.isArray(value)) throw new Error("target.network_mocks must be an array.");
@@ -305,6 +338,13 @@ function normalizeExpectedTexts(value, index) {
305
338
  if (!texts.length) throw new Error(`checks[${index}] selector_text_order expected_texts must contain non-empty strings.`);
306
339
  return texts;
307
340
  }
341
+ function normalizeStringList(value, label) {
342
+ if (value === void 0) return void 0;
343
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
344
+ const values = value.map((item) => String(item).replace(/\s+/g, " ").trim()).filter(Boolean);
345
+ if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
346
+ return values;
347
+ }
308
348
  function normalizeCheck(input, index) {
309
349
  if (!isRecord(input)) throw new Error(`checks[${index}] must be an object.`);
310
350
  const type = stringValue(input.type);
@@ -353,6 +393,10 @@ function normalizeCheck(input, index) {
353
393
  text: stringValue(input.text),
354
394
  pattern: stringValue(input.pattern),
355
395
  flags: stringValue(input.flags),
396
+ allowed_console_texts: normalizeStringList(input.allowed_console_texts ?? input.allowedConsoleTexts ?? input.allow_console_texts ?? input.allowConsoleTexts, `checks[${index}] allowed_console_texts`),
397
+ allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
398
+ 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`),
399
+ allowed_page_error_patterns: normalizeStringList(input.allowed_page_error_patterns ?? input.allowedPageErrorPatterns ?? input.allow_page_error_patterns ?? input.allowPageErrorPatterns, `checks[${index}] allowed_page_error_patterns`),
356
400
  min_count: numberValue(input.min_count),
357
401
  max_overflow_px: numberValue(input.max_overflow_px),
358
402
  timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
@@ -516,6 +560,18 @@ function matchText(sample, check) {
516
560
  }
517
561
  return sample.includes(check.text || "");
518
562
  }
563
+ function matchesAllowedMessage(message, texts, patterns) {
564
+ const sample = String(message || "");
565
+ if (texts?.some((text) => sample.includes(text))) return true;
566
+ for (const pattern of patterns || []) {
567
+ try {
568
+ if (new RegExp(pattern).test(sample)) return true;
569
+ } catch {
570
+ continue;
571
+ }
572
+ }
573
+ return false;
574
+ }
519
575
  function normalizeRoutePath(path) {
520
576
  const value = path || "/";
521
577
  if (value === "/") return "/";
@@ -802,14 +858,28 @@ function assessCheckFromEvidence(check, evidence) {
802
858
  };
803
859
  }
804
860
  if (check.type === "no_fatal_console_errors") {
805
- const fatalCount = (evidence.console?.fatal_count || 0) + (evidence.page_errors?.length || 0);
861
+ const fatalConsoleEvents = (evidence.console?.events || []).filter((event) => event.type === "error" || event.type === "assert");
862
+ const allowedConsoleEvents = fatalConsoleEvents.filter((event) => matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
863
+ const unallowedConsoleEvents = fatalConsoleEvents.filter((event) => !matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
864
+ const pageErrors = evidence.page_errors || [];
865
+ const allowedPageErrors = pageErrors.filter((error) => matchesAllowedMessage(error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
866
+ const unallowedPageErrors = pageErrors.filter((error) => !matchesAllowedMessage(error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
867
+ const fatalCount = unallowedConsoleEvents.length + unallowedPageErrors.length;
806
868
  return {
807
869
  type: check.type,
808
870
  label: checkLabel(check),
809
871
  status: fatalCount ? "failed" : "passed",
810
872
  evidence: {
811
- console_fatal_count: evidence.console?.fatal_count || 0,
812
- page_error_count: evidence.page_errors?.length || 0
873
+ console_fatal_count: unallowedConsoleEvents.length,
874
+ page_error_count: unallowedPageErrors.length,
875
+ total_console_fatal_count: fatalConsoleEvents.length,
876
+ total_page_error_count: pageErrors.length,
877
+ allowed_console_fatal_count: allowedConsoleEvents.length,
878
+ allowed_page_error_count: allowedPageErrors.length,
879
+ allowed_console_texts: check.allowed_console_texts || [],
880
+ allowed_console_patterns: check.allowed_console_patterns || [],
881
+ allowed_page_error_texts: check.allowed_page_error_texts || [],
882
+ allowed_page_error_patterns: check.allowed_page_error_patterns || []
813
883
  },
814
884
  message: fatalCount ? `${fatalCount} fatal browser error(s) were captured.` : void 0
815
885
  };
@@ -872,12 +942,15 @@ function assessNetworkMocksFromEvidence(profile, evidence) {
872
942
  const requiredMocks = mocks.filter((mock) => mock.required !== false);
873
943
  for (const mock of requiredMocks) {
874
944
  const hits = events.filter((event) => event.label === mock.label && event.ok !== false).length;
875
- if (hits < 1) {
945
+ const requiredHitCount = requiredNetworkMockHitCount(mock);
946
+ if (hits < requiredHitCount) {
876
947
  failed.push({
877
948
  label: mock.label,
878
949
  url: mock.url,
879
950
  method: mock.method || null,
880
- reason: "required_mock_not_hit"
951
+ reason: hits < 1 ? "required_mock_not_hit" : "required_mock_hit_count_not_met",
952
+ required_hit_count: requiredHitCount,
953
+ hit_count: hits
881
954
  });
882
955
  }
883
956
  }
@@ -903,11 +976,21 @@ function assessNetworkMocksFromEvidence(profile, evidence) {
903
976
  mock.label,
904
977
  events.filter((event) => event.label === mock.label && event.ok !== false).length
905
978
  ])),
979
+ required_hits_by_label: Object.fromEntries(requiredMocks.map((mock) => [
980
+ mock.label,
981
+ requiredNetworkMockHitCount(mock)
982
+ ])),
906
983
  failed
907
984
  },
908
985
  message: failed.length ? `Network mocks failed or were not hit for ${failed.length} mock(s).` : void 0
909
986
  };
910
987
  }
988
+ function requiredNetworkMockHitCount(mock) {
989
+ if (mock.required === false) return 0;
990
+ if (mock.required_hit_count !== void 0) return mock.required_hit_count;
991
+ if (Array.isArray(mock.responses) && mock.responses.length) return mock.responses.length;
992
+ return 1;
993
+ }
911
994
  function profileStatusFromEvidence(profile, evidence, checks) {
912
995
  if (!evidence) return "proof_insufficient";
913
996
  const viewports = evidence.viewports || [];
@@ -1065,6 +1148,16 @@ function textMatches(sample, check) {
1065
1148
  }
1066
1149
  return String(sample || "").includes(check.text || "");
1067
1150
  }
1151
+ function matchesAllowedMessage(message, texts, patterns) {
1152
+ const sample = String(message || "");
1153
+ if ((texts || []).some((text) => sample.includes(text))) return true;
1154
+ for (const pattern of patterns || []) {
1155
+ try {
1156
+ if (new RegExp(pattern).test(sample)) return true;
1157
+ } catch {}
1158
+ }
1159
+ return false;
1160
+ }
1068
1161
  function textSequenceForCheck(viewport, check) {
1069
1162
  const key = check.selector || "";
1070
1163
  const sequence = viewport.text_sequences && viewport.text_sequences[key];
@@ -1192,6 +1285,12 @@ function horizontalBoundsOverflowPx(value) {
1192
1285
  }
1193
1286
  return roundPixels(max);
1194
1287
  }
1288
+ function requiredNetworkMockHitCount(mock) {
1289
+ if (!mock || mock.required === false) return 0;
1290
+ if (Number.isInteger(mock.required_hit_count) && mock.required_hit_count > 0) return mock.required_hit_count;
1291
+ if (Array.isArray(mock.responses) && mock.responses.length) return mock.responses.length;
1292
+ return 1;
1293
+ }
1195
1294
  function assessProfile(profile, evidence) {
1196
1295
  const checks = [];
1197
1296
  const viewports = evidence.viewports || [];
@@ -1201,12 +1300,15 @@ function assessProfile(profile, evidence) {
1201
1300
  const failed = [];
1202
1301
  for (const mock of requiredMocks) {
1203
1302
  const hits = events.filter((event) => event && event.label === mock.label && event.ok !== false).length;
1204
- if (hits < 1) {
1303
+ const requiredHitCount = requiredNetworkMockHitCount(mock);
1304
+ if (hits < requiredHitCount) {
1205
1305
  failed.push({
1206
1306
  label: mock.label,
1207
1307
  url: mock.url,
1208
1308
  method: mock.method || null,
1209
- reason: "required_mock_not_hit",
1309
+ reason: hits < 1 ? "required_mock_not_hit" : "required_mock_hit_count_not_met",
1310
+ required_hit_count: requiredHitCount,
1311
+ hit_count: hits,
1210
1312
  });
1211
1313
  }
1212
1314
  }
@@ -1221,8 +1323,10 @@ function assessProfile(profile, evidence) {
1221
1323
  }
1222
1324
  }
1223
1325
  const hitsByLabel = {};
1326
+ const requiredHitsByLabel = {};
1224
1327
  for (const mock of profile.target.network_mocks) {
1225
1328
  hitsByLabel[mock.label] = events.filter((event) => event && event.label === mock.label && event.ok !== false).length;
1329
+ if (mock && mock.required !== false) requiredHitsByLabel[mock.label] = requiredNetworkMockHitCount(mock);
1226
1330
  }
1227
1331
  checks.push({
1228
1332
  type: "network_mocks_succeeded",
@@ -1233,6 +1337,7 @@ function assessProfile(profile, evidence) {
1233
1337
  required_count: requiredMocks.length,
1234
1338
  hit_count: events.filter((event) => event && event.ok !== false).length,
1235
1339
  hits_by_label: hitsByLabel,
1340
+ required_hits_by_label: requiredHitsByLabel,
1236
1341
  failed,
1237
1342
  },
1238
1343
  message: failed.length ? "Network mocks failed or were not hit for " + failed.length + " mock(s)." : undefined,
@@ -1499,12 +1604,29 @@ function assessProfile(profile, evidence) {
1499
1604
  continue;
1500
1605
  }
1501
1606
  if (check.type === "no_fatal_console_errors") {
1502
- const fatalCount = ((evidence.console && evidence.console.fatal_count) || 0) + ((evidence.page_errors || []).length);
1607
+ const fatalConsoleEvents = ((evidence.console && evidence.console.events) || []).filter((event) => event && (event.type === "error" || event.type === "assert"));
1608
+ const allowedConsoleEvents = fatalConsoleEvents.filter((event) => matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
1609
+ const unallowedConsoleEvents = fatalConsoleEvents.filter((event) => !matchesAllowedMessage(event.text, check.allowed_console_texts, check.allowed_console_patterns));
1610
+ const pageErrors = evidence.page_errors || [];
1611
+ const allowedPageErrors = pageErrors.filter((error) => matchesAllowedMessage(error && error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
1612
+ const unallowedPageErrors = pageErrors.filter((error) => !matchesAllowedMessage(error && error.message, check.allowed_page_error_texts, check.allowed_page_error_patterns));
1613
+ const fatalCount = unallowedConsoleEvents.length + unallowedPageErrors.length;
1503
1614
  checks.push({
1504
1615
  type: check.type,
1505
1616
  label: check.label || check.type,
1506
1617
  status: fatalCount ? "failed" : "passed",
1507
- evidence: { console_fatal_count: (evidence.console && evidence.console.fatal_count) || 0, page_error_count: (evidence.page_errors || []).length },
1618
+ evidence: {
1619
+ console_fatal_count: unallowedConsoleEvents.length,
1620
+ page_error_count: unallowedPageErrors.length,
1621
+ total_console_fatal_count: fatalConsoleEvents.length,
1622
+ total_page_error_count: pageErrors.length,
1623
+ allowed_console_fatal_count: allowedConsoleEvents.length,
1624
+ allowed_page_error_count: allowedPageErrors.length,
1625
+ allowed_console_texts: check.allowed_console_texts || [],
1626
+ allowed_console_patterns: check.allowed_console_patterns || [],
1627
+ allowed_page_error_texts: check.allowed_page_error_texts || [],
1628
+ allowed_page_error_patterns: check.allowed_page_error_patterns || [],
1629
+ },
1508
1630
  message: fatalCount ? String(fatalCount) + " fatal browser error(s) were captured." : undefined,
1509
1631
  });
1510
1632
  continue;
@@ -1614,6 +1736,7 @@ async function setupLocatorVisible(locator, index) {
1614
1736
  }
1615
1737
  async function registerNetworkMocks(mocks) {
1616
1738
  for (const mock of mocks || []) {
1739
+ let hitCount = 0;
1617
1740
  await page.route(mock.url, async (route) => {
1618
1741
  const request = route.request();
1619
1742
  const method = request.method ? request.method() : "";
@@ -1622,22 +1745,31 @@ async function registerNetworkMocks(mocks) {
1622
1745
  return;
1623
1746
  }
1624
1747
  try {
1625
- const headers = { ...(mock.headers || {}) };
1626
- let body = mock.body || "";
1627
- let contentType = mock.content_type || headers["content-type"] || headers["Content-Type"] || "text/plain";
1628
- if (mock.body_json !== undefined) {
1629
- body = JSON.stringify(mock.body_json);
1630
- contentType = mock.content_type || headers["content-type"] || headers["Content-Type"] || "application/json";
1748
+ const responses = Array.isArray(mock.responses) ? mock.responses : [];
1749
+ const hitIndex = hitCount;
1750
+ hitCount += 1;
1751
+ const responseIndex = responses.length ? Math.min(hitIndex, responses.length - 1) : null;
1752
+ const response = responseIndex === null ? mock : responses[responseIndex];
1753
+ const headers = { ...(response.headers || mock.headers || {}) };
1754
+ let body = response.body || "";
1755
+ let contentType = response.content_type || headers["content-type"] || headers["Content-Type"] || "text/plain";
1756
+ if (response.body_json !== undefined) {
1757
+ body = JSON.stringify(response.body_json);
1758
+ contentType = response.content_type || headers["content-type"] || headers["Content-Type"] || "application/json";
1631
1759
  }
1632
1760
  networkMockEvents.push({
1633
1761
  ok: true,
1634
1762
  label: mock.label,
1763
+ response_label: response.label || null,
1764
+ hit_index: hitIndex,
1765
+ response_index: responseIndex,
1766
+ sequence_reused: responseIndex !== null && hitIndex >= responses.length,
1635
1767
  url: request.url(),
1636
1768
  method,
1637
- status: mock.status || 200,
1769
+ status: response.status || mock.status || 200,
1638
1770
  });
1639
1771
  await route.fulfill({
1640
- status: mock.status || 200,
1772
+ status: response.status || mock.status || 200,
1641
1773
  headers,
1642
1774
  contentType,
1643
1775
  body,