@sudobility/testomniac_runner_service 0.1.51 → 0.1.52

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.
Files changed (30) hide show
  1. package/dist/analyzer/page-analyzer.d.ts +11 -0
  2. package/dist/analyzer/page-analyzer.d.ts.map +1 -1
  3. package/dist/analyzer/page-analyzer.js +394 -44
  4. package/dist/analyzer/page-analyzer.js.map +1 -1
  5. package/dist/expertise/tester/commerce-checks.d.ts.map +1 -1
  6. package/dist/expertise/tester/commerce-checks.js +79 -0
  7. package/dist/expertise/tester/commerce-checks.js.map +1 -1
  8. package/dist/expertise/tester/dialog-feedback-checks.d.ts.map +1 -1
  9. package/dist/expertise/tester/dialog-feedback-checks.js +27 -10
  10. package/dist/expertise/tester/dialog-feedback-checks.js.map +1 -1
  11. package/dist/expertise/tester/form-checks.d.ts +6 -0
  12. package/dist/expertise/tester/form-checks.d.ts.map +1 -1
  13. package/dist/expertise/tester/form-checks.js +50 -0
  14. package/dist/expertise/tester/form-checks.js.map +1 -1
  15. package/dist/expertise/tester/keyboard-disclosure-checks.d.ts +4 -0
  16. package/dist/expertise/tester/keyboard-disclosure-checks.d.ts.map +1 -1
  17. package/dist/expertise/tester/keyboard-disclosure-checks.js +30 -0
  18. package/dist/expertise/tester/keyboard-disclosure-checks.js.map +1 -1
  19. package/dist/expertise/tester/list-workflow-checks.js +12 -2
  20. package/dist/expertise/tester/list-workflow-checks.js.map +1 -1
  21. package/dist/expertise/tester/search-checks.d.ts.map +1 -1
  22. package/dist/expertise/tester/search-checks.js +23 -0
  23. package/dist/expertise/tester/search-checks.js.map +1 -1
  24. package/dist/expertise/tester/variant-checks.d.ts.map +1 -1
  25. package/dist/expertise/tester/variant-checks.js +36 -0
  26. package/dist/expertise/tester/variant-checks.js.map +1 -1
  27. package/dist/expertise/tester-expertise.d.ts.map +1 -1
  28. package/dist/expertise/tester-expertise.js +8 -2
  29. package/dist/expertise/tester-expertise.js.map +1 -1
  30. package/package.json +1 -1
@@ -720,6 +720,16 @@ export class PageAnalyzer {
720
720
  steps,
721
721
  globalExpectations: [
722
722
  ...this.defaultFlowExpectations(`Form ${formLabel} should execute cleanly`),
723
+ ...(formType === "login" || formType === "signup"
724
+ ? [
725
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${formLabel} should advance authentication state after a successful submit`, {
726
+ severity: ExpectationSeverity.ShouldPass,
727
+ }),
728
+ this.makeExpectation(ExpectationType.ErrorStateCleared, `${formLabel} should not leave a visible error state after a successful authentication submit`, {
729
+ severity: ExpectationSeverity.ShouldPass,
730
+ }),
731
+ ]
732
+ : []),
723
733
  this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit without client-side errors`),
724
734
  this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} should trigger a backend mutation request`, {
725
735
  expectedValue: "mutation",
@@ -742,7 +752,7 @@ export class PageAnalyzer {
742
752
  };
743
753
  }
744
754
  buildNegativeFormTestElement(form, formLabel, formType, omittedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
745
- const steps = this.buildFormSteps(form, validValues, omittedField.selector);
755
+ const steps = this.buildFormSteps(form, validValues, omittedField.selector, this.buildValuePreservationExpectations(form, validValues, omittedField.selector));
746
756
  return {
747
757
  title: `Form Negative — ${formLabel} (missing ${this.fieldLabel(omittedField)})`,
748
758
  type: "form_negative",
@@ -755,6 +765,7 @@ export class PageAnalyzer {
755
765
  globalExpectations: [
756
766
  ...this.defaultFlowExpectations(`Negative form check for ${formLabel}`),
757
767
  this.makeExpectation(ExpectationType.ValidationMessageVisible, `Validation feedback should appear when ${this.fieldLabel(omittedField)} is omitted`),
768
+ this.makeExpectation(ExpectationType.ErrorStateVisible, `Omitting ${this.fieldLabel(omittedField)} should surface an error state`),
758
769
  this.makeExpectation("required_error_shown_for_field", `${this.fieldLabel(omittedField)} should show a required-field error when omitted`, {
759
770
  targetPath: omittedField.selector,
760
771
  }),
@@ -763,7 +774,7 @@ export class PageAnalyzer {
763
774
  };
764
775
  }
765
776
  buildFormCorrectionTestElement(form, formLabel, formType, correctedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
766
- const steps = this.buildFormSteps(form, validValues, correctedField.selector);
777
+ const steps = this.buildFormSteps(form, validValues, correctedField.selector, this.buildValuePreservationExpectations(form, validValues, correctedField.selector));
767
778
  const correctionValue = validValues[correctedField.selector];
768
779
  if (correctionValue) {
769
780
  const correctionStep = this.buildFieldStep(correctedField, correctionValue, this.makeExpectation("field_error_clears_after_fix", `${this.fieldLabel(correctedField)} should clear its validation error after correction`, {
@@ -785,6 +796,7 @@ export class PageAnalyzer {
785
796
  steps,
786
797
  globalExpectations: [
787
798
  ...this.defaultFlowExpectations(`Correction flow for ${formLabel}`),
799
+ this.makeExpectation(ExpectationType.ErrorStateCleared, `Error state for ${formLabel} should clear after correcting ${this.fieldLabel(correctedField)}`),
788
800
  this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit after correcting ${this.fieldLabel(correctedField)}`),
789
801
  this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} after correction should trigger a backend mutation request`, {
790
802
  expectedValue: "mutation",
@@ -858,7 +870,7 @@ export class PageAnalyzer {
858
870
  uid,
859
871
  };
860
872
  }
861
- buildFormSteps(form, valuesBySelector, omittedSelector) {
873
+ buildFormSteps(form, valuesBySelector, omittedSelector, postSubmitExpectations = []) {
862
874
  const steps = [];
863
875
  for (const field of form.fields) {
864
876
  if (field.selector === omittedSelector)
@@ -876,7 +888,7 @@ export class PageAnalyzer {
876
888
  if (step)
877
889
  steps.push(step);
878
890
  }
879
- steps.push(...this.buildSubmitSteps(form.submitSelector));
891
+ steps.push(...this.buildSubmitSteps(form.submitSelector, postSubmitExpectations));
880
892
  return steps;
881
893
  }
882
894
  buildFieldStep(field, value, trailingExpectation) {
@@ -944,7 +956,7 @@ export class PageAnalyzer {
944
956
  continueOnFailure: false,
945
957
  };
946
958
  }
947
- buildSubmitSteps(submitSelector) {
959
+ buildSubmitSteps(submitSelector, postSubmitExpectations = []) {
948
960
  if (!submitSelector)
949
961
  return [];
950
962
  return [
@@ -965,12 +977,39 @@ export class PageAnalyzer {
965
977
  playwrightCode: "await page.waitForLoadState('networkidle')",
966
978
  description: "Wait for post-submit state",
967
979
  },
968
- expectations: [],
980
+ expectations: postSubmitExpectations,
969
981
  description: "Wait for post-submit state",
970
982
  continueOnFailure: true,
971
983
  },
972
984
  ];
973
985
  }
986
+ buildValuePreservationExpectations(form, valuesBySelector, omittedSelector) {
987
+ const expectations = [];
988
+ for (const field of form.fields) {
989
+ if (field.selector === omittedSelector)
990
+ continue;
991
+ const analyzerField = field;
992
+ const fieldIsImmutable = Boolean(analyzerField.disabled ||
993
+ analyzerField.readOnly ||
994
+ this.looksVisuallyDisabledField(analyzerField));
995
+ if (fieldIsImmutable)
996
+ continue;
997
+ const value = valuesBySelector[field.selector];
998
+ if (!value || this.isSkippableFieldType(field))
999
+ continue;
1000
+ if (this.isCheckboxLike(field)) {
1001
+ expectations.push(this.makeExpectation(ExpectationType.ElementChecked, `${this.fieldLabel(field)} should preserve its selected state after validation feedback`, {
1002
+ targetPath: field.selector,
1003
+ }));
1004
+ continue;
1005
+ }
1006
+ expectations.push(this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(field)} should preserve its value after validation feedback`, {
1007
+ targetPath: field.selector,
1008
+ expectedValue: value,
1009
+ }));
1010
+ }
1011
+ return expectations;
1012
+ }
974
1013
  planFormValues(form, actionableItems) {
975
1014
  const values = {};
976
1015
  for (const field of form.fields) {
@@ -1132,6 +1171,29 @@ export class PageAnalyzer {
1132
1171
  ],
1133
1172
  uid,
1134
1173
  },
1174
+ {
1175
+ title: `Search Recovery — ${formLabel}`,
1176
+ type: "form",
1177
+ sizeClass,
1178
+ surface_tags: ["form", "search", "recovery"],
1179
+ priority: 3,
1180
+ startingPageStateId,
1181
+ startingPath: currentPath,
1182
+ steps: [
1183
+ ...this.buildFormSteps(form, noResultsValues, undefined),
1184
+ ...this.buildSearchRecoverySteps(form, searchField, "test"),
1185
+ ],
1186
+ globalExpectations: [
1187
+ ...this.defaultFlowExpectations(`Search recovery flow ${formLabel}`),
1188
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `Recovering search results via ${formLabel} should issue GET requests`, {
1189
+ expectedValue: "GET",
1190
+ timeoutMs: 3000,
1191
+ expectedTextTokens: ["search", "q=", "query", "term"],
1192
+ }),
1193
+ this.makeExpectation(ExpectationType.LoadingCompletes, `Search recovery for ${formLabel} should finish loading`),
1194
+ ],
1195
+ uid,
1196
+ },
1135
1197
  ];
1136
1198
  const clearAction = actionableItems.find(item => this.isSearchClearItem(item));
1137
1199
  if (clearAction) {
@@ -1161,6 +1223,22 @@ export class PageAnalyzer {
1161
1223
  }
1162
1224
  return tests;
1163
1225
  }
1226
+ buildSearchRecoverySteps(form, searchField, recoveryValue) {
1227
+ const steps = [];
1228
+ const refillStep = this.buildFieldStep(searchField, recoveryValue);
1229
+ if (refillStep) {
1230
+ steps.push(refillStep);
1231
+ }
1232
+ steps.push(...this.buildSubmitSteps(form.submitSelector, [
1233
+ this.makeExpectation(ExpectationType.ResultsChanged, `${this.fieldLabel(searchField)} should recover from the empty state to a different result set`),
1234
+ this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(searchField)} should preserve the recovery query after resubmission`, {
1235
+ targetPath: searchField.selector,
1236
+ expectedValue: recoveryValue,
1237
+ }),
1238
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Recovered search results should finish loading"),
1239
+ ]));
1240
+ return steps;
1241
+ }
1164
1242
  describeForm(form, index) {
1165
1243
  const namedField = form.fields.find(field => field.label || field.name);
1166
1244
  const descriptor = namedField?.label || namedField?.name || `form ${index + 1}`;
@@ -1169,8 +1247,10 @@ export class PageAnalyzer {
1169
1247
  buildSemanticJourneyTestElements(context) {
1170
1248
  const items = context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector));
1171
1249
  const journeys = [];
1250
+ const collectionCount = this.estimateCollectionCount(context.html);
1172
1251
  const addToCart = items.find(item => this.isAddToCartItem(item));
1173
1252
  const checkout = items.find(item => this.isCheckoutItem(item));
1253
+ const createCollectionAction = items.find(item => this.isCreateCollectionAction(item));
1174
1254
  if (addToCart && checkout) {
1175
1255
  journeys.push(this.buildJourneyTestElement("Commerce journey", ["commerce", "cart", "checkout"], context, [
1176
1256
  this.buildJourneyAction(addToCart, "Add item to cart", [
@@ -1207,29 +1287,63 @@ export class PageAnalyzer {
1207
1287
  }
1208
1288
  const removeItem = items.find(item => this.isRemoveItemAction(item));
1209
1289
  if (removeItem) {
1290
+ const removeExpectations = [
1291
+ this.makeExpectation("navigation_or_state_changed", "Removing an item should change the page state"),
1292
+ this.makeExpectation("count_changed", "Removing an item should update a visible count", {
1293
+ expectedCountDelta: -1,
1294
+ }),
1295
+ this.makeExpectation("cart_summary_changed", "Removing an item should update the cart summary"),
1296
+ this.makeExpectation("row_count_changed", "Removing an item should change the visible row or item count", {
1297
+ expectedCountDelta: -1,
1298
+ }),
1299
+ this.makeExpectation("results_changed", "Removing an item should change the visible collection state"),
1300
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Removing an item should trigger a backend mutation request", {
1301
+ expectedValue: "mutation",
1302
+ timeoutMs: 3000,
1303
+ }),
1304
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Removing an item should not trigger duplicate mutation requests"),
1305
+ this.makeExpectation("feedback_visible", "Removing an item should provide visible feedback", {
1306
+ expectedTextTokens: ["removed", "updated", "cart", "bag"],
1307
+ forbiddenTextTokens: ["error", "failed"],
1308
+ }),
1309
+ this.makeExpectation("feedback_not_duplicated", "Removing an item should not show duplicate feedback messages"),
1310
+ this.makeExpectation("loading_completes", "Removal flow should complete loading"),
1311
+ this.makeExpectation("page_responsive", "Page should remain responsive after removal"),
1312
+ ];
1313
+ if (this.estimateCollectionCount(context.html) <= 1) {
1314
+ removeExpectations.push(this.makeExpectation(ExpectationType.EmptyStateVisible, "Removing the last visible item should show an empty state or zero-results message", {
1315
+ expectedTextTokens: [
1316
+ "no items",
1317
+ "no products",
1318
+ "empty",
1319
+ "no results",
1320
+ "nothing found",
1321
+ ],
1322
+ severity: ExpectationSeverity.ShouldPass,
1323
+ }));
1324
+ }
1210
1325
  journeys.push(this.buildJourneyTestElement("Remove item from collection", ["commerce", "remove"], context, [
1211
- this.buildJourneyAction(removeItem, "Remove item", [
1212
- this.makeExpectation("navigation_or_state_changed", "Removing an item should change the page state"),
1213
- this.makeExpectation("count_changed", "Removing an item should update a visible count", {
1214
- expectedCountDelta: -1,
1215
- }),
1216
- this.makeExpectation("cart_summary_changed", "Removing an item should update the cart summary"),
1217
- this.makeExpectation("row_count_changed", "Removing an item should change the visible row or item count", {
1218
- expectedCountDelta: -1,
1219
- }),
1220
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Removing an item should trigger a backend mutation request", {
1221
- expectedValue: "mutation",
1222
- timeoutMs: 3000,
1223
- }),
1224
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Removing an item should not trigger duplicate mutation requests"),
1225
- this.makeExpectation("feedback_visible", "Removing an item should provide visible feedback", {
1226
- expectedTextTokens: ["removed", "updated", "cart", "bag"],
1227
- forbiddenTextTokens: ["error", "failed"],
1228
- }),
1229
- this.makeExpectation("feedback_not_duplicated", "Removing an item should not show duplicate feedback messages"),
1230
- this.makeExpectation("loading_completes", "Removal flow should complete loading"),
1231
- this.makeExpectation("page_responsive", "Page should remain responsive after removal"),
1232
- ]),
1326
+ this.buildJourneyAction(removeItem, "Remove item", removeExpectations),
1327
+ ]));
1328
+ }
1329
+ if (createCollectionAction) {
1330
+ const createExpectations = [
1331
+ this.makeExpectation("navigation_or_state_changed", "Creating or adding a record should change the page state"),
1332
+ this.makeExpectation("row_count_changed", "Creating or adding a record should increase the visible row or item count", {
1333
+ expectedCountDelta: 1,
1334
+ }),
1335
+ this.makeExpectation("results_changed", "Creating or adding a record should change the visible collection state"),
1336
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Creating or adding a record should trigger a backend mutation request", {
1337
+ expectedValue: "mutation",
1338
+ timeoutMs: 3000,
1339
+ }),
1340
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Creating or adding a record should not trigger duplicate mutation requests"),
1341
+ this.makeExpectation("feedback_not_duplicated", "Creating or adding a record should not duplicate visible feedback"),
1342
+ this.makeExpectation("loading_completes", "Create/add flow should complete loading"),
1343
+ this.makeExpectation("page_responsive", "Page should remain responsive after creating or adding a record"),
1344
+ ];
1345
+ journeys.push(this.buildJourneyTestElement("Create or add collection record", ["list", "crud", "create"], context, [
1346
+ this.buildJourneyAction(createCollectionAction, "Create or add record", createExpectations),
1233
1347
  ]));
1234
1348
  }
1235
1349
  const authEntry = items.find(item => this.isAuthEntryItem(item));
@@ -1242,6 +1356,51 @@ export class PageAnalyzer {
1242
1356
  ]),
1243
1357
  ]));
1244
1358
  }
1359
+ const protectedAction = items.find(item => item !== authEntry && this.isProtectedActionItem(item));
1360
+ if (authEntry && protectedAction) {
1361
+ journeys.push(this.buildJourneyTestElement("Protected action auth gate journey", ["auth", "protected"], context, [
1362
+ this.buildJourneyAction(protectedAction, "Open protected action", [
1363
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Protected action should open a gated state, redirect, or login requirement"),
1364
+ this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive when auth gating is triggered"),
1365
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Protected action gate should settle cleanly"),
1366
+ ]),
1367
+ ]));
1368
+ }
1369
+ const logoutAction = items.find(item => this.isLogoutAction(item));
1370
+ if (logoutAction) {
1371
+ journeys.push(this.buildJourneyTestElement("Logout journey", ["auth", "logout"], context, [
1372
+ this.buildJourneyAction(logoutAction, "Log out", [
1373
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Logging out should change application state"),
1374
+ this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive during logout"),
1375
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Logout should settle cleanly"),
1376
+ this.makeExpectation(ExpectationType.FeedbackVisible, "Logout should provide visible confirmation or a clear state change", {
1377
+ forbiddenTextTokens: ["error", "failed"],
1378
+ }),
1379
+ this.makeExpectation(ExpectationType.ErrorStateCleared, "Logout should not leave visible recoverable error state behind", {
1380
+ severity: ExpectationSeverity.ShouldPass,
1381
+ }),
1382
+ ]),
1383
+ ]));
1384
+ }
1385
+ const retryAction = items.find(item => this.isRetryAction(item));
1386
+ if (retryAction) {
1387
+ journeys.push(this.buildJourneyTestElement("Retry recovery journey", ["recovery", "retry"], context, [
1388
+ this.buildJourneyAction(retryAction, "Retry failed action", [
1389
+ this.makeExpectation(ExpectationType.ErrorStateCleared, "Retrying should clear any visible recoverable error state"),
1390
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Retrying should trigger a follow-up request or state transition", {
1391
+ expectedValue: "ANY",
1392
+ timeoutMs: 3000,
1393
+ }),
1394
+ this.makeExpectation("navigation_or_state_changed", "Retrying should change page state or visibly advance recovery"),
1395
+ this.makeExpectation("loading_completes", "Retry recovery should complete loading"),
1396
+ this.makeExpectation("page_responsive", "Page should remain responsive during retry recovery"),
1397
+ this.makeExpectation("feedback_not_duplicated", "Retry recovery should not duplicate visible feedback"),
1398
+ this.makeExpectation("feedback_visible", "Retry recovery should show updated feedback or state confirmation", {
1399
+ forbiddenTextTokens: ["error", "failed", "try again"],
1400
+ }),
1401
+ ]),
1402
+ ]));
1403
+ }
1245
1404
  const mediaCandidate = items.find(item => this.isMediaOpenItem(item));
1246
1405
  if (mediaCandidate) {
1247
1406
  const mediaExpectations = [
@@ -1255,19 +1414,33 @@ export class PageAnalyzer {
1255
1414
  this.buildJourneyAction(mediaCandidate, "Open media", mediaExpectations),
1256
1415
  ]));
1257
1416
  }
1258
- const quantityAction = items.find(item => this.isQuantityAction(item));
1259
- if (quantityAction) {
1260
- journeys.push(this.buildJourneyTestElement("Quantity adjustment journey", ["commerce", "quantity"], context, [
1261
- this.buildJourneyAction(quantityAction, "Adjust quantity", [
1262
- this.makeExpectation("count_changed", "Adjusting quantity should update a visible count or quantity indicator"),
1263
- this.makeExpectation("cart_summary_changed", "Adjusting quantity should update the summary values"),
1264
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Adjusting quantity should trigger a backend mutation request", {
1265
- expectedValue: "mutation",
1266
- timeoutMs: 3000,
1267
- }),
1268
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Adjusting quantity should not trigger duplicate mutation requests"),
1269
- this.makeExpectation("loading_completes", "Quantity adjustment should complete loading"),
1270
- ]),
1417
+ for (const quantityAction of items.filter(item => this.isQuantityAction(item))) {
1418
+ const quantityDelta = this.inferQuantityDelta(quantityAction);
1419
+ const quantityExpectations = [
1420
+ this.makeExpectation("count_changed", "Adjusting quantity should update a visible count or quantity indicator", quantityDelta == null
1421
+ ? undefined
1422
+ : { expectedCountDelta: quantityDelta }),
1423
+ this.makeExpectation("cart_summary_changed", "Adjusting quantity should update subtotal, totals, or line pricing"),
1424
+ this.makeExpectation("results_changed", "Adjusting quantity should change the visible cart or line-item state"),
1425
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Adjusting quantity should trigger a backend mutation request", {
1426
+ expectedValue: "mutation",
1427
+ timeoutMs: 3000,
1428
+ }),
1429
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Adjusting quantity should not trigger duplicate mutation requests"),
1430
+ this.makeExpectation("feedback_not_duplicated", "Quantity adjustment should not duplicate visible feedback"),
1431
+ this.makeExpectation("loading_completes", "Quantity adjustment should complete loading"),
1432
+ this.makeExpectation("page_responsive", "Page should remain responsive during quantity adjustment"),
1433
+ ].filter(Boolean);
1434
+ journeys.push(this.buildJourneyTestElement(quantityDelta === -1
1435
+ ? "Quantity decrease journey"
1436
+ : quantityDelta === 1
1437
+ ? "Quantity increase journey"
1438
+ : "Quantity adjustment journey", ["commerce", "quantity"], context, [
1439
+ this.buildJourneyAction(quantityAction, quantityDelta === -1
1440
+ ? "Decrease quantity"
1441
+ : quantityDelta === 1
1442
+ ? "Increase quantity"
1443
+ : "Adjust quantity", quantityExpectations),
1271
1444
  ]));
1272
1445
  }
1273
1446
  const filterAction = items.find(item => this.isFilterAction(item));
@@ -1306,7 +1479,9 @@ export class PageAnalyzer {
1306
1479
  journeys.push(this.buildJourneyTestElement("Pagination journey", ["list", "pagination"], context, [
1307
1480
  this.buildJourneyAction(paginationAction, "Paginate list", [
1308
1481
  this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Pagination should change the visible list state"),
1482
+ this.makeExpectation("results_changed", "Pagination should change the visible collection contents"),
1309
1483
  this.makeExpectation("row_count_changed", "Pagination should change the visible rows or items"),
1484
+ this.makeExpectation("collection_order_changed", "Pagination should change the visible collection ordering or composition"),
1310
1485
  this.makeExpectation(ExpectationType.LoadingCompletes, "Pagination should complete loading"),
1311
1486
  ]),
1312
1487
  ]));
@@ -1330,6 +1505,24 @@ export class PageAnalyzer {
1330
1505
  ]),
1331
1506
  ]));
1332
1507
  }
1508
+ if (collectionCount > 0 && removeItem && createCollectionAction) {
1509
+ journeys.push(this.buildJourneyTestElement("Collection mutation recovery journey", ["list", "crud", "recovery"], context, [
1510
+ this.buildJourneyAction(removeItem, "Remove record", [
1511
+ this.makeExpectation("row_count_changed", "Removing a record should change the visible collection", {
1512
+ expectedCountDelta: -1,
1513
+ }),
1514
+ this.makeExpectation("results_changed", "Removing a record should change the visible collection contents"),
1515
+ ]),
1516
+ this.waitStep(500, "Wait for collection mutation"),
1517
+ this.buildJourneyAction(createCollectionAction, "Add record back", [
1518
+ this.makeExpectation("row_count_changed", "Adding a record back should restore visible collection size", {
1519
+ expectedCountDelta: 1,
1520
+ }),
1521
+ this.makeExpectation("results_changed", "Adding a record back should change the visible collection contents again"),
1522
+ this.makeExpectation("loading_completes", "Collection recovery should complete loading"),
1523
+ ]),
1524
+ ]));
1525
+ }
1333
1526
  const backCandidate = items.find(item => item.actionKind === "navigate" ||
1334
1527
  this.isMediaOpenItem(item) ||
1335
1528
  this.isAuthEntryItem(item));
@@ -1504,9 +1697,23 @@ export class PageAnalyzer {
1504
1697
  !item.disabled &&
1505
1698
  Boolean(item.selector) &&
1506
1699
  this.isVariantSelector(item));
1507
- return items
1700
+ const tests = items
1508
1701
  .map(item => this.buildVariantTestElement(item, context))
1509
1702
  .filter((item) => Boolean(item));
1703
+ const purchaseAction = context.actionableItems.find(item => item.visible &&
1704
+ !item.disabled &&
1705
+ Boolean(item.selector) &&
1706
+ (this.isAddToCartItem(item) || this.isCheckoutItem(item)));
1707
+ for (const item of items) {
1708
+ const purchaseJourney = this.buildVariantPurchaseJourney(item, purchaseAction, context);
1709
+ if (purchaseJourney)
1710
+ tests.push(purchaseJourney);
1711
+ const requiredField = this.findRequiredVariantField(item, context.forms);
1712
+ if (requiredField && purchaseAction) {
1713
+ tests.push(this.buildRequiredVariantGuardTestElement(item, requiredField, purchaseAction, context));
1714
+ }
1715
+ }
1716
+ return tests;
1510
1717
  }
1511
1718
  buildVariantTestElement(item, context) {
1512
1719
  const plannedValue = this.extractSelectableValue(item);
@@ -1548,6 +1755,110 @@ export class PageAnalyzer {
1548
1755
  uid: context.uid,
1549
1756
  };
1550
1757
  }
1758
+ buildVariantPurchaseJourney(item, purchaseAction, context) {
1759
+ const plannedValue = this.extractSelectableValue(item);
1760
+ if (!plannedValue || !item.selector || !purchaseAction?.selector) {
1761
+ return null;
1762
+ }
1763
+ const label = this.describeActionableItem(item);
1764
+ const purchaseLabel = this.describeActionableItem(purchaseAction);
1765
+ const purchaseExpectations = this.isAddToCartItem(purchaseAction)
1766
+ ? [
1767
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a backend mutation request after selecting ${label}`, {
1768
+ expectedValue: "mutation",
1769
+ timeoutMs: 3000,
1770
+ }),
1771
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, `${purchaseLabel} should not trigger duplicate mutation requests after selecting ${label}`),
1772
+ this.makeExpectation(ExpectationType.CountChanged, `${purchaseLabel} should update a visible count after selecting ${label}`, {
1773
+ expectedCountDelta: 1,
1774
+ }),
1775
+ this.makeExpectation(ExpectationType.CartSummaryChanged, `${purchaseLabel} should update cart summary after selecting ${label}`),
1776
+ this.makeExpectation(ExpectationType.FeedbackVisible, `${purchaseLabel} should provide visible feedback after selecting ${label}`, {
1777
+ expectedTextTokens: ["added", "success", "cart", "bag"],
1778
+ forbiddenTextTokens: ["error", "failed"],
1779
+ }),
1780
+ ]
1781
+ : [
1782
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a request or transition after selecting ${label}`, {
1783
+ expectedValue: "ANY",
1784
+ timeoutMs: 3000,
1785
+ expectedTextTokens: ["checkout", "buy", "order"],
1786
+ }),
1787
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${purchaseLabel} should advance the purchase flow after selecting ${label}`),
1788
+ ];
1789
+ return {
1790
+ title: `Variant purchase journey ${label}`,
1791
+ type: "interaction",
1792
+ sizeClass: context.sizeClass,
1793
+ surface_tags: ["variant", "purchase"],
1794
+ priority: 2,
1795
+ startingPageStateId: context.currentPageStateId,
1796
+ startingPath: context.currentPath,
1797
+ steps: [
1798
+ {
1799
+ action: {
1800
+ actionType: PlaywrightAction.SelectOption,
1801
+ path: item.selector,
1802
+ value: plannedValue,
1803
+ playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(plannedValue)}')`,
1804
+ description: `Select variant ${label}`,
1805
+ },
1806
+ expectations: [
1807
+ this.makeExpectation(ExpectationType.InputValue, `Selecting ${label} should update the chosen option`, {
1808
+ targetPath: item.selector,
1809
+ expectedValue: plannedValue,
1810
+ }),
1811
+ this.makeExpectation(ExpectationType.VariantStateChanged, `Selecting ${label} should update product state before purchase`, {
1812
+ targetPath: item.selector,
1813
+ expectedValue: plannedValue,
1814
+ }),
1815
+ ],
1816
+ description: `Select variant ${label}`,
1817
+ continueOnFailure: false,
1818
+ },
1819
+ this.buildJourneyAction(purchaseAction, `Purchase with selected ${label}`, [
1820
+ ...purchaseExpectations,
1821
+ this.makeExpectation(ExpectationType.LoadingCompletes, `${purchaseLabel} should complete loading after selecting ${label}`),
1822
+ this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive while completing ${purchaseLabel}`),
1823
+ ]),
1824
+ ],
1825
+ globalExpectations: this.defaultFlowExpectations(`Variant purchase journey for ${label} should execute cleanly`),
1826
+ uid: context.uid,
1827
+ };
1828
+ }
1829
+ buildRequiredVariantGuardTestElement(item, requiredField, purchaseAction, context) {
1830
+ const label = this.describeActionableItem(item);
1831
+ const purchaseLabel = this.describeActionableItem(purchaseAction);
1832
+ return {
1833
+ title: `Variant required guard ${label}`,
1834
+ type: "interaction",
1835
+ sizeClass: context.sizeClass,
1836
+ surface_tags: ["variant", "validation", "guard"],
1837
+ priority: 3,
1838
+ startingPageStateId: context.currentPageStateId,
1839
+ startingPath: context.currentPath,
1840
+ steps: [
1841
+ this.buildJourneyAction(purchaseAction, `Attempt ${purchaseLabel} without selecting ${label}`, [
1842
+ this.makeExpectation(ExpectationType.RequiredErrorShownForField, `${label} should show required validation before ${purchaseLabel}`, {
1843
+ targetPath: requiredField.selector,
1844
+ }),
1845
+ this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive when ${purchaseLabel} is blocked by missing ${label}`),
1846
+ ]),
1847
+ ],
1848
+ globalExpectations: this.defaultFlowExpectations(`${label} should be enforced before ${purchaseLabel}`),
1849
+ uid: context.uid,
1850
+ };
1851
+ }
1852
+ findRequiredVariantField(item, forms) {
1853
+ for (const form of forms) {
1854
+ const field = form.fields.find(field => field.selector === item.selector &&
1855
+ field.required &&
1856
+ this.isSearchField(field) === false);
1857
+ if (field)
1858
+ return field;
1859
+ }
1860
+ return undefined;
1861
+ }
1551
1862
  buildDisclosureToggleTestElement(item, startingPath, sizeClass, uid, startingPageStateId) {
1552
1863
  const label = this.describeActionableItem(item);
1553
1864
  return {
@@ -1600,7 +1911,11 @@ export class PageAnalyzer {
1600
1911
  playwrightCode: `await page.locator('${item.selector}').focus()`,
1601
1912
  description: `Focus ${label}`,
1602
1913
  },
1603
- expectations: [],
1914
+ expectations: [
1915
+ this.makeExpectation(ExpectationType.ElementFocused, `${label} should be keyboard-focusable`, {
1916
+ targetPath: item.selector,
1917
+ }),
1918
+ ],
1604
1919
  description: `Focus ${label}`,
1605
1920
  continueOnFailure: false,
1606
1921
  },
@@ -1888,11 +2203,36 @@ export class PageAnalyzer {
1888
2203
  return /\b(checkout|proceed to checkout|submit order|place order)\b/.test(this.semanticText(item));
1889
2204
  }
1890
2205
  isRemoveItemAction(item) {
1891
- return /\b(remove|delete|trash|clear item|remove item)\b/.test(this.semanticText(item));
2206
+ return /\b(remove|delete|trash|clear item|remove item|dismiss|archive|hide item|close item)\b/.test(this.semanticText(item));
2207
+ }
2208
+ estimateCollectionCount(html) {
2209
+ const tableRows = (html.match(/<tr\b/gi) ?? []).length;
2210
+ if (tableRows > 0)
2211
+ return tableRows;
2212
+ const listItems = (html.match(/<li\b/gi) ?? []).length;
2213
+ if (listItems > 0)
2214
+ return listItems;
2215
+ const cards = (html.match(/<(?:article|section|div)\b[^>]*(?:product|card|result|item|row)[^>]*>/gi) ?? []).length;
2216
+ return cards;
1892
2217
  }
1893
2218
  isAuthEntryItem(item) {
1894
2219
  return /\b(sign up|register|create account|sign in|log in|login)\b/.test(this.semanticText(item));
1895
2220
  }
2221
+ isProtectedActionItem(item) {
2222
+ return /\b(checkout|pricing|view price|account|settings|billing|subscription|dashboard|admin|manage account|saved items|favorites|download)\b/.test(this.semanticText(item));
2223
+ }
2224
+ isLogoutAction(item) {
2225
+ return /\b(log out|logout|sign out)\b/.test(this.semanticText(item));
2226
+ }
2227
+ isRetryAction(item) {
2228
+ return /\b(retry|try again|resend|send again|reload|refresh|reconnect|resume)\b/.test(this.semanticText(item));
2229
+ }
2230
+ isCreateCollectionAction(item) {
2231
+ const text = this.semanticText(item);
2232
+ if (this.isAddToCartItem(item))
2233
+ return false;
2234
+ return /\b(add row|add record|add item|new row|new record|create item|create record|add another|new item)\b/.test(text);
2235
+ }
1896
2236
  isMediaOpenItem(item) {
1897
2237
  const text = this.semanticText(item);
1898
2238
  return (item.tagName === "VIDEO" ||
@@ -1906,6 +2246,16 @@ export class PageAnalyzer {
1906
2246
  isQuantityAction(item) {
1907
2247
  return /\b(qty|quantity|increase|decrease|increment|decrement|plus|minus)\b/.test(this.semanticText(item));
1908
2248
  }
2249
+ inferQuantityDelta(item) {
2250
+ const text = this.semanticText(item);
2251
+ if (/\b(decrease|decrement|minus|remove one|lower qty|lower quantity)\b/.test(text)) {
2252
+ return -1;
2253
+ }
2254
+ if (/\b(increase|increment|plus|add one|raise qty|raise quantity)\b/.test(text)) {
2255
+ return 1;
2256
+ }
2257
+ return undefined;
2258
+ }
1909
2259
  isFilterAction(item) {
1910
2260
  return /\b(filter|refine|apply filter|show results|category|brand|size|color|price)\b/.test(this.semanticText(item));
1911
2261
  }