@sudobility/testomniac_runner_service 0.1.51 → 0.1.53

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 (36) hide show
  1. package/dist/analyzer/page-analyzer.d.ts +16 -0
  2. package/dist/analyzer/page-analyzer.d.ts.map +1 -1
  3. package/dist/analyzer/page-analyzer.js +495 -56
  4. package/dist/analyzer/page-analyzer.js.map +1 -1
  5. package/dist/browser/dom-snapshot.d.ts.map +1 -1
  6. package/dist/browser/dom-snapshot.js +84 -0
  7. package/dist/browser/dom-snapshot.js.map +1 -1
  8. package/dist/expertise/tester/commerce-checks.d.ts.map +1 -1
  9. package/dist/expertise/tester/commerce-checks.js +79 -0
  10. package/dist/expertise/tester/commerce-checks.js.map +1 -1
  11. package/dist/expertise/tester/dialog-feedback-checks.d.ts.map +1 -1
  12. package/dist/expertise/tester/dialog-feedback-checks.js +27 -10
  13. package/dist/expertise/tester/dialog-feedback-checks.js.map +1 -1
  14. package/dist/expertise/tester/form-checks.d.ts +6 -0
  15. package/dist/expertise/tester/form-checks.d.ts.map +1 -1
  16. package/dist/expertise/tester/form-checks.js +50 -0
  17. package/dist/expertise/tester/form-checks.js.map +1 -1
  18. package/dist/expertise/tester/keyboard-disclosure-checks.d.ts +4 -0
  19. package/dist/expertise/tester/keyboard-disclosure-checks.d.ts.map +1 -1
  20. package/dist/expertise/tester/keyboard-disclosure-checks.js +30 -0
  21. package/dist/expertise/tester/keyboard-disclosure-checks.js.map +1 -1
  22. package/dist/expertise/tester/list-workflow-checks.js +12 -2
  23. package/dist/expertise/tester/list-workflow-checks.js.map +1 -1
  24. package/dist/expertise/tester/search-checks.d.ts.map +1 -1
  25. package/dist/expertise/tester/search-checks.js +23 -0
  26. package/dist/expertise/tester/search-checks.js.map +1 -1
  27. package/dist/expertise/tester/variant-checks.d.ts.map +1 -1
  28. package/dist/expertise/tester/variant-checks.js +36 -0
  29. package/dist/expertise/tester/variant-checks.js.map +1 -1
  30. package/dist/expertise/tester-expertise.d.ts.map +1 -1
  31. package/dist/expertise/tester-expertise.js +8 -2
  32. package/dist/expertise/tester-expertise.js.map +1 -1
  33. package/dist/extractors/helpers.d.ts.map +1 -1
  34. package/dist/extractors/helpers.js +3 -0
  35. package/dist/extractors/helpers.js.map +1 -1
  36. package/package.json +1 -1
@@ -83,12 +83,12 @@ export class PageAnalyzer {
83
83
  ? await context.api.getItemsByPageState(context.beginningPageStateId)
84
84
  : [];
85
85
  const beginningKeys = new Set(beginningItems.map(item => this.getItemKey(item)).filter(Boolean));
86
- const revealedItems = context.actionableItems.filter(item => {
86
+ const revealedItems = this.selectRepresentativeItems(context.actionableItems.filter(item => {
87
87
  if (!this.isMouseActionable(item))
88
88
  return false;
89
89
  const key = this.getItemKey(item);
90
90
  return Boolean(key) && !beginningKeys.has(key);
91
- });
91
+ }));
92
92
  const hoveredItem = context.actionableItems.find(item => item.selector === selector) ?? null;
93
93
  if (revealedItems.length === 0 && hoveredItem) {
94
94
  const clickCase = this.buildClickTestElement(hoveredItem, context.currentPath, context.sizeClass, context.uid, context.currentPageStateId, context.currentTestElementId);
@@ -112,7 +112,7 @@ export class PageAnalyzer {
112
112
  if (context.pageRequiresLogin)
113
113
  return;
114
114
  const { api, runnerId, sizeClass, uid, navigationSurface, bundleRun } = context;
115
- const links = context.actionableItems.filter(item => item.actionKind === "navigate" && item.href && item.visible);
115
+ const links = this.selectRepresentativeItems(context.actionableItems.filter(item => item.actionKind === "navigate" && item.href && item.visible));
116
116
  // Ensure navigation surface is in the bundle
117
117
  await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, navigationSurface.id);
118
118
  // Ensure a surface run exists for the navigation surface under this bundle run
@@ -134,8 +134,7 @@ export class PageAnalyzer {
134
134
  async generateScaffoldTestElements(context) {
135
135
  const { api, runnerId, sizeClass, uid, bundleRun } = context;
136
136
  for (const scaffold of context.scaffolds) {
137
- // Find actionable items belonging to this scaffold
138
- const scaffoldItems = context.actionableItems.filter(item => item.scaffoldId != null && this.isSurfaceCandidate(item));
137
+ const scaffoldItems = this.selectRepresentativeItems(this.getScaffoldSurfaceItems(context, scaffold));
139
138
  if (scaffoldItems.length === 0)
140
139
  continue;
141
140
  // Ensure a test surface for this scaffold
@@ -168,6 +167,14 @@ export class PageAnalyzer {
168
167
  }
169
168
  }
170
169
  }
170
+ getScaffoldSurfaceItems(context, scaffold) {
171
+ return context.actionableItems.filter(item => {
172
+ if (!this.isSurfaceCandidate(item) || !item.selector)
173
+ return false;
174
+ return (context.scaffoldSelectorByItemSelector[item.selector] ===
175
+ scaffold.selector);
176
+ });
177
+ }
171
178
  async generateRenderTestElements(context) {
172
179
  const { api, runnerId, sizeClass, uid, bundleRun } = context;
173
180
  const surface = await api.ensureTestSurface(runnerId, {
@@ -320,10 +327,10 @@ export class PageAnalyzer {
320
327
  async generateDialogLifecycleTestElements(context) {
321
328
  if (!this.pageHasOpenDialog(context.html))
322
329
  return;
323
- const closeCandidates = context.actionableItems.filter(item => item.visible &&
330
+ const closeCandidates = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible &&
324
331
  !item.disabled &&
325
332
  item.selector &&
326
- this.isDialogCloseItem(item));
333
+ this.isDialogCloseItem(item)));
327
334
  const tests = [];
328
335
  for (const item of closeCandidates) {
329
336
  tests.push(this.buildDialogCloseTestElement(item, context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
@@ -415,7 +422,7 @@ export class PageAnalyzer {
415
422
  async generateContentTestElements(context) {
416
423
  const { api, runnerId, sizeClass, uid, bundleRun, pageId: _pageId, } = context;
417
424
  // Content items are those NOT in a scaffold
418
- const contentItems = context.actionableItems.filter(item => item.scaffoldId == null && this.isSurfaceCandidate(item));
425
+ const contentItems = this.selectRepresentativeItems(context.actionableItems.filter(item => item.scaffoldId == null && this.isSurfaceCandidate(item)));
419
426
  if (contentItems.length === 0)
420
427
  return;
421
428
  const surfaceTitle = `Page: ${context.currentPath}`;
@@ -720,6 +727,16 @@ export class PageAnalyzer {
720
727
  steps,
721
728
  globalExpectations: [
722
729
  ...this.defaultFlowExpectations(`Form ${formLabel} should execute cleanly`),
730
+ ...(formType === "login" || formType === "signup"
731
+ ? [
732
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${formLabel} should advance authentication state after a successful submit`, {
733
+ severity: ExpectationSeverity.ShouldPass,
734
+ }),
735
+ this.makeExpectation(ExpectationType.ErrorStateCleared, `${formLabel} should not leave a visible error state after a successful authentication submit`, {
736
+ severity: ExpectationSeverity.ShouldPass,
737
+ }),
738
+ ]
739
+ : []),
723
740
  this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit without client-side errors`),
724
741
  this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} should trigger a backend mutation request`, {
725
742
  expectedValue: "mutation",
@@ -742,7 +759,7 @@ export class PageAnalyzer {
742
759
  };
743
760
  }
744
761
  buildNegativeFormTestElement(form, formLabel, formType, omittedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
745
- const steps = this.buildFormSteps(form, validValues, omittedField.selector);
762
+ const steps = this.buildFormSteps(form, validValues, omittedField.selector, this.buildValuePreservationExpectations(form, validValues, omittedField.selector));
746
763
  return {
747
764
  title: `Form Negative — ${formLabel} (missing ${this.fieldLabel(omittedField)})`,
748
765
  type: "form_negative",
@@ -755,6 +772,7 @@ export class PageAnalyzer {
755
772
  globalExpectations: [
756
773
  ...this.defaultFlowExpectations(`Negative form check for ${formLabel}`),
757
774
  this.makeExpectation(ExpectationType.ValidationMessageVisible, `Validation feedback should appear when ${this.fieldLabel(omittedField)} is omitted`),
775
+ this.makeExpectation(ExpectationType.ErrorStateVisible, `Omitting ${this.fieldLabel(omittedField)} should surface an error state`),
758
776
  this.makeExpectation("required_error_shown_for_field", `${this.fieldLabel(omittedField)} should show a required-field error when omitted`, {
759
777
  targetPath: omittedField.selector,
760
778
  }),
@@ -763,7 +781,7 @@ export class PageAnalyzer {
763
781
  };
764
782
  }
765
783
  buildFormCorrectionTestElement(form, formLabel, formType, correctedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
766
- const steps = this.buildFormSteps(form, validValues, correctedField.selector);
784
+ const steps = this.buildFormSteps(form, validValues, correctedField.selector, this.buildValuePreservationExpectations(form, validValues, correctedField.selector));
767
785
  const correctionValue = validValues[correctedField.selector];
768
786
  if (correctionValue) {
769
787
  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 +803,7 @@ export class PageAnalyzer {
785
803
  steps,
786
804
  globalExpectations: [
787
805
  ...this.defaultFlowExpectations(`Correction flow for ${formLabel}`),
806
+ this.makeExpectation(ExpectationType.ErrorStateCleared, `Error state for ${formLabel} should clear after correcting ${this.fieldLabel(correctedField)}`),
788
807
  this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit after correcting ${this.fieldLabel(correctedField)}`),
789
808
  this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} after correction should trigger a backend mutation request`, {
790
809
  expectedValue: "mutation",
@@ -858,7 +877,7 @@ export class PageAnalyzer {
858
877
  uid,
859
878
  };
860
879
  }
861
- buildFormSteps(form, valuesBySelector, omittedSelector) {
880
+ buildFormSteps(form, valuesBySelector, omittedSelector, postSubmitExpectations = []) {
862
881
  const steps = [];
863
882
  for (const field of form.fields) {
864
883
  if (field.selector === omittedSelector)
@@ -876,7 +895,7 @@ export class PageAnalyzer {
876
895
  if (step)
877
896
  steps.push(step);
878
897
  }
879
- steps.push(...this.buildSubmitSteps(form.submitSelector));
898
+ steps.push(...this.buildSubmitSteps(form.submitSelector, postSubmitExpectations));
880
899
  return steps;
881
900
  }
882
901
  buildFieldStep(field, value, trailingExpectation) {
@@ -944,7 +963,7 @@ export class PageAnalyzer {
944
963
  continueOnFailure: false,
945
964
  };
946
965
  }
947
- buildSubmitSteps(submitSelector) {
966
+ buildSubmitSteps(submitSelector, postSubmitExpectations = []) {
948
967
  if (!submitSelector)
949
968
  return [];
950
969
  return [
@@ -965,12 +984,39 @@ export class PageAnalyzer {
965
984
  playwrightCode: "await page.waitForLoadState('networkidle')",
966
985
  description: "Wait for post-submit state",
967
986
  },
968
- expectations: [],
987
+ expectations: postSubmitExpectations,
969
988
  description: "Wait for post-submit state",
970
989
  continueOnFailure: true,
971
990
  },
972
991
  ];
973
992
  }
993
+ buildValuePreservationExpectations(form, valuesBySelector, omittedSelector) {
994
+ const expectations = [];
995
+ for (const field of form.fields) {
996
+ if (field.selector === omittedSelector)
997
+ continue;
998
+ const analyzerField = field;
999
+ const fieldIsImmutable = Boolean(analyzerField.disabled ||
1000
+ analyzerField.readOnly ||
1001
+ this.looksVisuallyDisabledField(analyzerField));
1002
+ if (fieldIsImmutable)
1003
+ continue;
1004
+ const value = valuesBySelector[field.selector];
1005
+ if (!value || this.isSkippableFieldType(field))
1006
+ continue;
1007
+ if (this.isCheckboxLike(field)) {
1008
+ expectations.push(this.makeExpectation(ExpectationType.ElementChecked, `${this.fieldLabel(field)} should preserve its selected state after validation feedback`, {
1009
+ targetPath: field.selector,
1010
+ }));
1011
+ continue;
1012
+ }
1013
+ expectations.push(this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(field)} should preserve its value after validation feedback`, {
1014
+ targetPath: field.selector,
1015
+ expectedValue: value,
1016
+ }));
1017
+ }
1018
+ return expectations;
1019
+ }
974
1020
  planFormValues(form, actionableItems) {
975
1021
  const values = {};
976
1022
  for (const field of form.fields) {
@@ -1132,6 +1178,29 @@ export class PageAnalyzer {
1132
1178
  ],
1133
1179
  uid,
1134
1180
  },
1181
+ {
1182
+ title: `Search Recovery — ${formLabel}`,
1183
+ type: "form",
1184
+ sizeClass,
1185
+ surface_tags: ["form", "search", "recovery"],
1186
+ priority: 3,
1187
+ startingPageStateId,
1188
+ startingPath: currentPath,
1189
+ steps: [
1190
+ ...this.buildFormSteps(form, noResultsValues, undefined),
1191
+ ...this.buildSearchRecoverySteps(form, searchField, "test"),
1192
+ ],
1193
+ globalExpectations: [
1194
+ ...this.defaultFlowExpectations(`Search recovery flow ${formLabel}`),
1195
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `Recovering search results via ${formLabel} should issue GET requests`, {
1196
+ expectedValue: "GET",
1197
+ timeoutMs: 3000,
1198
+ expectedTextTokens: ["search", "q=", "query", "term"],
1199
+ }),
1200
+ this.makeExpectation(ExpectationType.LoadingCompletes, `Search recovery for ${formLabel} should finish loading`),
1201
+ ],
1202
+ uid,
1203
+ },
1135
1204
  ];
1136
1205
  const clearAction = actionableItems.find(item => this.isSearchClearItem(item));
1137
1206
  if (clearAction) {
@@ -1161,16 +1230,34 @@ export class PageAnalyzer {
1161
1230
  }
1162
1231
  return tests;
1163
1232
  }
1233
+ buildSearchRecoverySteps(form, searchField, recoveryValue) {
1234
+ const steps = [];
1235
+ const refillStep = this.buildFieldStep(searchField, recoveryValue);
1236
+ if (refillStep) {
1237
+ steps.push(refillStep);
1238
+ }
1239
+ steps.push(...this.buildSubmitSteps(form.submitSelector, [
1240
+ this.makeExpectation(ExpectationType.ResultsChanged, `${this.fieldLabel(searchField)} should recover from the empty state to a different result set`),
1241
+ this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(searchField)} should preserve the recovery query after resubmission`, {
1242
+ targetPath: searchField.selector,
1243
+ expectedValue: recoveryValue,
1244
+ }),
1245
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Recovered search results should finish loading"),
1246
+ ]));
1247
+ return steps;
1248
+ }
1164
1249
  describeForm(form, index) {
1165
1250
  const namedField = form.fields.find(field => field.label || field.name);
1166
1251
  const descriptor = namedField?.label || namedField?.name || `form ${index + 1}`;
1167
1252
  return `${descriptor} @ ${form.selector}`;
1168
1253
  }
1169
1254
  buildSemanticJourneyTestElements(context) {
1170
- const items = context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector));
1255
+ const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector)));
1171
1256
  const journeys = [];
1257
+ const collectionCount = this.estimateCollectionCount(context.html);
1172
1258
  const addToCart = items.find(item => this.isAddToCartItem(item));
1173
1259
  const checkout = items.find(item => this.isCheckoutItem(item));
1260
+ const createCollectionAction = items.find(item => this.isCreateCollectionAction(item));
1174
1261
  if (addToCart && checkout) {
1175
1262
  journeys.push(this.buildJourneyTestElement("Commerce journey", ["commerce", "cart", "checkout"], context, [
1176
1263
  this.buildJourneyAction(addToCart, "Add item to cart", [
@@ -1207,29 +1294,63 @@ export class PageAnalyzer {
1207
1294
  }
1208
1295
  const removeItem = items.find(item => this.isRemoveItemAction(item));
1209
1296
  if (removeItem) {
1297
+ const removeExpectations = [
1298
+ this.makeExpectation("navigation_or_state_changed", "Removing an item should change the page state"),
1299
+ this.makeExpectation("count_changed", "Removing an item should update a visible count", {
1300
+ expectedCountDelta: -1,
1301
+ }),
1302
+ this.makeExpectation("cart_summary_changed", "Removing an item should update the cart summary"),
1303
+ this.makeExpectation("row_count_changed", "Removing an item should change the visible row or item count", {
1304
+ expectedCountDelta: -1,
1305
+ }),
1306
+ this.makeExpectation("results_changed", "Removing an item should change the visible collection state"),
1307
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Removing an item should trigger a backend mutation request", {
1308
+ expectedValue: "mutation",
1309
+ timeoutMs: 3000,
1310
+ }),
1311
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Removing an item should not trigger duplicate mutation requests"),
1312
+ this.makeExpectation("feedback_visible", "Removing an item should provide visible feedback", {
1313
+ expectedTextTokens: ["removed", "updated", "cart", "bag"],
1314
+ forbiddenTextTokens: ["error", "failed"],
1315
+ }),
1316
+ this.makeExpectation("feedback_not_duplicated", "Removing an item should not show duplicate feedback messages"),
1317
+ this.makeExpectation("loading_completes", "Removal flow should complete loading"),
1318
+ this.makeExpectation("page_responsive", "Page should remain responsive after removal"),
1319
+ ];
1320
+ if (this.estimateCollectionCount(context.html) <= 1) {
1321
+ removeExpectations.push(this.makeExpectation(ExpectationType.EmptyStateVisible, "Removing the last visible item should show an empty state or zero-results message", {
1322
+ expectedTextTokens: [
1323
+ "no items",
1324
+ "no products",
1325
+ "empty",
1326
+ "no results",
1327
+ "nothing found",
1328
+ ],
1329
+ severity: ExpectationSeverity.ShouldPass,
1330
+ }));
1331
+ }
1210
1332
  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
- ]),
1333
+ this.buildJourneyAction(removeItem, "Remove item", removeExpectations),
1334
+ ]));
1335
+ }
1336
+ if (createCollectionAction) {
1337
+ const createExpectations = [
1338
+ this.makeExpectation("navigation_or_state_changed", "Creating or adding a record should change the page state"),
1339
+ this.makeExpectation("row_count_changed", "Creating or adding a record should increase the visible row or item count", {
1340
+ expectedCountDelta: 1,
1341
+ }),
1342
+ this.makeExpectation("results_changed", "Creating or adding a record should change the visible collection state"),
1343
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Creating or adding a record should trigger a backend mutation request", {
1344
+ expectedValue: "mutation",
1345
+ timeoutMs: 3000,
1346
+ }),
1347
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Creating or adding a record should not trigger duplicate mutation requests"),
1348
+ this.makeExpectation("feedback_not_duplicated", "Creating or adding a record should not duplicate visible feedback"),
1349
+ this.makeExpectation("loading_completes", "Create/add flow should complete loading"),
1350
+ this.makeExpectation("page_responsive", "Page should remain responsive after creating or adding a record"),
1351
+ ];
1352
+ journeys.push(this.buildJourneyTestElement("Create or add collection record", ["list", "crud", "create"], context, [
1353
+ this.buildJourneyAction(createCollectionAction, "Create or add record", createExpectations),
1233
1354
  ]));
1234
1355
  }
1235
1356
  const authEntry = items.find(item => this.isAuthEntryItem(item));
@@ -1242,6 +1363,51 @@ export class PageAnalyzer {
1242
1363
  ]),
1243
1364
  ]));
1244
1365
  }
1366
+ const protectedAction = items.find(item => item !== authEntry && this.isProtectedActionItem(item));
1367
+ if (authEntry && protectedAction) {
1368
+ journeys.push(this.buildJourneyTestElement("Protected action auth gate journey", ["auth", "protected"], context, [
1369
+ this.buildJourneyAction(protectedAction, "Open protected action", [
1370
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Protected action should open a gated state, redirect, or login requirement"),
1371
+ this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive when auth gating is triggered"),
1372
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Protected action gate should settle cleanly"),
1373
+ ]),
1374
+ ]));
1375
+ }
1376
+ const logoutAction = items.find(item => this.isLogoutAction(item));
1377
+ if (logoutAction) {
1378
+ journeys.push(this.buildJourneyTestElement("Logout journey", ["auth", "logout"], context, [
1379
+ this.buildJourneyAction(logoutAction, "Log out", [
1380
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Logging out should change application state"),
1381
+ this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive during logout"),
1382
+ this.makeExpectation(ExpectationType.LoadingCompletes, "Logout should settle cleanly"),
1383
+ this.makeExpectation(ExpectationType.FeedbackVisible, "Logout should provide visible confirmation or a clear state change", {
1384
+ forbiddenTextTokens: ["error", "failed"],
1385
+ }),
1386
+ this.makeExpectation(ExpectationType.ErrorStateCleared, "Logout should not leave visible recoverable error state behind", {
1387
+ severity: ExpectationSeverity.ShouldPass,
1388
+ }),
1389
+ ]),
1390
+ ]));
1391
+ }
1392
+ const retryAction = items.find(item => this.isRetryAction(item));
1393
+ if (retryAction) {
1394
+ journeys.push(this.buildJourneyTestElement("Retry recovery journey", ["recovery", "retry"], context, [
1395
+ this.buildJourneyAction(retryAction, "Retry failed action", [
1396
+ this.makeExpectation(ExpectationType.ErrorStateCleared, "Retrying should clear any visible recoverable error state"),
1397
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Retrying should trigger a follow-up request or state transition", {
1398
+ expectedValue: "ANY",
1399
+ timeoutMs: 3000,
1400
+ }),
1401
+ this.makeExpectation("navigation_or_state_changed", "Retrying should change page state or visibly advance recovery"),
1402
+ this.makeExpectation("loading_completes", "Retry recovery should complete loading"),
1403
+ this.makeExpectation("page_responsive", "Page should remain responsive during retry recovery"),
1404
+ this.makeExpectation("feedback_not_duplicated", "Retry recovery should not duplicate visible feedback"),
1405
+ this.makeExpectation("feedback_visible", "Retry recovery should show updated feedback or state confirmation", {
1406
+ forbiddenTextTokens: ["error", "failed", "try again"],
1407
+ }),
1408
+ ]),
1409
+ ]));
1410
+ }
1245
1411
  const mediaCandidate = items.find(item => this.isMediaOpenItem(item));
1246
1412
  if (mediaCandidate) {
1247
1413
  const mediaExpectations = [
@@ -1255,19 +1421,33 @@ export class PageAnalyzer {
1255
1421
  this.buildJourneyAction(mediaCandidate, "Open media", mediaExpectations),
1256
1422
  ]));
1257
1423
  }
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
- ]),
1424
+ for (const quantityAction of items.filter(item => this.isQuantityAction(item))) {
1425
+ const quantityDelta = this.inferQuantityDelta(quantityAction);
1426
+ const quantityExpectations = [
1427
+ this.makeExpectation("count_changed", "Adjusting quantity should update a visible count or quantity indicator", quantityDelta == null
1428
+ ? undefined
1429
+ : { expectedCountDelta: quantityDelta }),
1430
+ this.makeExpectation("cart_summary_changed", "Adjusting quantity should update subtotal, totals, or line pricing"),
1431
+ this.makeExpectation("results_changed", "Adjusting quantity should change the visible cart or line-item state"),
1432
+ this.makeExpectation(ExpectationType.NetworkRequestMade, "Adjusting quantity should trigger a backend mutation request", {
1433
+ expectedValue: "mutation",
1434
+ timeoutMs: 3000,
1435
+ }),
1436
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Adjusting quantity should not trigger duplicate mutation requests"),
1437
+ this.makeExpectation("feedback_not_duplicated", "Quantity adjustment should not duplicate visible feedback"),
1438
+ this.makeExpectation("loading_completes", "Quantity adjustment should complete loading"),
1439
+ this.makeExpectation("page_responsive", "Page should remain responsive during quantity adjustment"),
1440
+ ].filter(Boolean);
1441
+ journeys.push(this.buildJourneyTestElement(quantityDelta === -1
1442
+ ? "Quantity decrease journey"
1443
+ : quantityDelta === 1
1444
+ ? "Quantity increase journey"
1445
+ : "Quantity adjustment journey", ["commerce", "quantity"], context, [
1446
+ this.buildJourneyAction(quantityAction, quantityDelta === -1
1447
+ ? "Decrease quantity"
1448
+ : quantityDelta === 1
1449
+ ? "Increase quantity"
1450
+ : "Adjust quantity", quantityExpectations),
1271
1451
  ]));
1272
1452
  }
1273
1453
  const filterAction = items.find(item => this.isFilterAction(item));
@@ -1306,7 +1486,9 @@ export class PageAnalyzer {
1306
1486
  journeys.push(this.buildJourneyTestElement("Pagination journey", ["list", "pagination"], context, [
1307
1487
  this.buildJourneyAction(paginationAction, "Paginate list", [
1308
1488
  this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Pagination should change the visible list state"),
1489
+ this.makeExpectation("results_changed", "Pagination should change the visible collection contents"),
1309
1490
  this.makeExpectation("row_count_changed", "Pagination should change the visible rows or items"),
1491
+ this.makeExpectation("collection_order_changed", "Pagination should change the visible collection ordering or composition"),
1310
1492
  this.makeExpectation(ExpectationType.LoadingCompletes, "Pagination should complete loading"),
1311
1493
  ]),
1312
1494
  ]));
@@ -1330,6 +1512,24 @@ export class PageAnalyzer {
1330
1512
  ]),
1331
1513
  ]));
1332
1514
  }
1515
+ if (collectionCount > 0 && removeItem && createCollectionAction) {
1516
+ journeys.push(this.buildJourneyTestElement("Collection mutation recovery journey", ["list", "crud", "recovery"], context, [
1517
+ this.buildJourneyAction(removeItem, "Remove record", [
1518
+ this.makeExpectation("row_count_changed", "Removing a record should change the visible collection", {
1519
+ expectedCountDelta: -1,
1520
+ }),
1521
+ this.makeExpectation("results_changed", "Removing a record should change the visible collection contents"),
1522
+ ]),
1523
+ this.waitStep(500, "Wait for collection mutation"),
1524
+ this.buildJourneyAction(createCollectionAction, "Add record back", [
1525
+ this.makeExpectation("row_count_changed", "Adding a record back should restore visible collection size", {
1526
+ expectedCountDelta: 1,
1527
+ }),
1528
+ this.makeExpectation("results_changed", "Adding a record back should change the visible collection contents again"),
1529
+ this.makeExpectation("loading_completes", "Collection recovery should complete loading"),
1530
+ ]),
1531
+ ]));
1532
+ }
1333
1533
  const backCandidate = items.find(item => item.actionKind === "navigate" ||
1334
1534
  this.isMediaOpenItem(item) ||
1335
1535
  this.isAuthEntryItem(item));
@@ -1467,7 +1667,7 @@ export class PageAnalyzer {
1467
1667
  return field.type === "checkbox" || field.type === "radio";
1468
1668
  }
1469
1669
  buildKeyboardAndDisclosureTestElements(context) {
1470
- const items = context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector));
1670
+ const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector)));
1471
1671
  const tests = [];
1472
1672
  for (const item of items) {
1473
1673
  if (this.isDisclosureItem(item)) {
@@ -1500,13 +1700,27 @@ export class PageAnalyzer {
1500
1700
  return tests;
1501
1701
  }
1502
1702
  buildVariantTestElements(context) {
1503
- const items = context.actionableItems.filter(item => item.visible &&
1703
+ const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible &&
1504
1704
  !item.disabled &&
1505
1705
  Boolean(item.selector) &&
1506
- this.isVariantSelector(item));
1507
- return items
1706
+ this.isVariantSelector(item)));
1707
+ const tests = items
1508
1708
  .map(item => this.buildVariantTestElement(item, context))
1509
1709
  .filter((item) => Boolean(item));
1710
+ const purchaseAction = context.actionableItems.find(item => item.visible &&
1711
+ !item.disabled &&
1712
+ Boolean(item.selector) &&
1713
+ (this.isAddToCartItem(item) || this.isCheckoutItem(item)));
1714
+ for (const item of items) {
1715
+ const purchaseJourney = this.buildVariantPurchaseJourney(item, purchaseAction, context);
1716
+ if (purchaseJourney)
1717
+ tests.push(purchaseJourney);
1718
+ const requiredField = this.findRequiredVariantField(item, context.forms);
1719
+ if (requiredField && purchaseAction) {
1720
+ tests.push(this.buildRequiredVariantGuardTestElement(item, requiredField, purchaseAction, context));
1721
+ }
1722
+ }
1723
+ return tests;
1510
1724
  }
1511
1725
  buildVariantTestElement(item, context) {
1512
1726
  const plannedValue = this.extractSelectableValue(item);
@@ -1548,6 +1762,110 @@ export class PageAnalyzer {
1548
1762
  uid: context.uid,
1549
1763
  };
1550
1764
  }
1765
+ buildVariantPurchaseJourney(item, purchaseAction, context) {
1766
+ const plannedValue = this.extractSelectableValue(item);
1767
+ if (!plannedValue || !item.selector || !purchaseAction?.selector) {
1768
+ return null;
1769
+ }
1770
+ const label = this.describeActionableItem(item);
1771
+ const purchaseLabel = this.describeActionableItem(purchaseAction);
1772
+ const purchaseExpectations = this.isAddToCartItem(purchaseAction)
1773
+ ? [
1774
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a backend mutation request after selecting ${label}`, {
1775
+ expectedValue: "mutation",
1776
+ timeoutMs: 3000,
1777
+ }),
1778
+ this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, `${purchaseLabel} should not trigger duplicate mutation requests after selecting ${label}`),
1779
+ this.makeExpectation(ExpectationType.CountChanged, `${purchaseLabel} should update a visible count after selecting ${label}`, {
1780
+ expectedCountDelta: 1,
1781
+ }),
1782
+ this.makeExpectation(ExpectationType.CartSummaryChanged, `${purchaseLabel} should update cart summary after selecting ${label}`),
1783
+ this.makeExpectation(ExpectationType.FeedbackVisible, `${purchaseLabel} should provide visible feedback after selecting ${label}`, {
1784
+ expectedTextTokens: ["added", "success", "cart", "bag"],
1785
+ forbiddenTextTokens: ["error", "failed"],
1786
+ }),
1787
+ ]
1788
+ : [
1789
+ this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a request or transition after selecting ${label}`, {
1790
+ expectedValue: "ANY",
1791
+ timeoutMs: 3000,
1792
+ expectedTextTokens: ["checkout", "buy", "order"],
1793
+ }),
1794
+ this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${purchaseLabel} should advance the purchase flow after selecting ${label}`),
1795
+ ];
1796
+ return {
1797
+ title: `Variant purchase journey ${label}`,
1798
+ type: "interaction",
1799
+ sizeClass: context.sizeClass,
1800
+ surface_tags: ["variant", "purchase"],
1801
+ priority: 2,
1802
+ startingPageStateId: context.currentPageStateId,
1803
+ startingPath: context.currentPath,
1804
+ steps: [
1805
+ {
1806
+ action: {
1807
+ actionType: PlaywrightAction.SelectOption,
1808
+ path: item.selector,
1809
+ value: plannedValue,
1810
+ playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(plannedValue)}')`,
1811
+ description: `Select variant ${label}`,
1812
+ },
1813
+ expectations: [
1814
+ this.makeExpectation(ExpectationType.InputValue, `Selecting ${label} should update the chosen option`, {
1815
+ targetPath: item.selector,
1816
+ expectedValue: plannedValue,
1817
+ }),
1818
+ this.makeExpectation(ExpectationType.VariantStateChanged, `Selecting ${label} should update product state before purchase`, {
1819
+ targetPath: item.selector,
1820
+ expectedValue: plannedValue,
1821
+ }),
1822
+ ],
1823
+ description: `Select variant ${label}`,
1824
+ continueOnFailure: false,
1825
+ },
1826
+ this.buildJourneyAction(purchaseAction, `Purchase with selected ${label}`, [
1827
+ ...purchaseExpectations,
1828
+ this.makeExpectation(ExpectationType.LoadingCompletes, `${purchaseLabel} should complete loading after selecting ${label}`),
1829
+ this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive while completing ${purchaseLabel}`),
1830
+ ]),
1831
+ ],
1832
+ globalExpectations: this.defaultFlowExpectations(`Variant purchase journey for ${label} should execute cleanly`),
1833
+ uid: context.uid,
1834
+ };
1835
+ }
1836
+ buildRequiredVariantGuardTestElement(item, requiredField, purchaseAction, context) {
1837
+ const label = this.describeActionableItem(item);
1838
+ const purchaseLabel = this.describeActionableItem(purchaseAction);
1839
+ return {
1840
+ title: `Variant required guard ${label}`,
1841
+ type: "interaction",
1842
+ sizeClass: context.sizeClass,
1843
+ surface_tags: ["variant", "validation", "guard"],
1844
+ priority: 3,
1845
+ startingPageStateId: context.currentPageStateId,
1846
+ startingPath: context.currentPath,
1847
+ steps: [
1848
+ this.buildJourneyAction(purchaseAction, `Attempt ${purchaseLabel} without selecting ${label}`, [
1849
+ this.makeExpectation(ExpectationType.RequiredErrorShownForField, `${label} should show required validation before ${purchaseLabel}`, {
1850
+ targetPath: requiredField.selector,
1851
+ }),
1852
+ this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive when ${purchaseLabel} is blocked by missing ${label}`),
1853
+ ]),
1854
+ ],
1855
+ globalExpectations: this.defaultFlowExpectations(`${label} should be enforced before ${purchaseLabel}`),
1856
+ uid: context.uid,
1857
+ };
1858
+ }
1859
+ findRequiredVariantField(item, forms) {
1860
+ for (const form of forms) {
1861
+ const field = form.fields.find(field => field.selector === item.selector &&
1862
+ field.required &&
1863
+ this.isSearchField(field) === false);
1864
+ if (field)
1865
+ return field;
1866
+ }
1867
+ return undefined;
1868
+ }
1551
1869
  buildDisclosureToggleTestElement(item, startingPath, sizeClass, uid, startingPageStateId) {
1552
1870
  const label = this.describeActionableItem(item);
1553
1871
  return {
@@ -1600,7 +1918,11 @@ export class PageAnalyzer {
1600
1918
  playwrightCode: `await page.locator('${item.selector}').focus()`,
1601
1919
  description: `Focus ${label}`,
1602
1920
  },
1603
- expectations: [],
1921
+ expectations: [
1922
+ this.makeExpectation(ExpectationType.ElementFocused, `${label} should be keyboard-focusable`, {
1923
+ targetPath: item.selector,
1924
+ }),
1925
+ ],
1604
1926
  description: `Focus ${label}`,
1605
1927
  continueOnFailure: false,
1606
1928
  },
@@ -1863,6 +2185,7 @@ export class PageAnalyzer {
1863
2185
  describeActionableItem(item) {
1864
2186
  return (item.accessibleName ||
1865
2187
  item.textContent ||
2188
+ String(item.attributes?._containerTitle ?? "") ||
1866
2189
  String(item.attributes?.labelText ?? "") ||
1867
2190
  String(item.attributes?.placeholder ?? "") ||
1868
2191
  item.selector);
@@ -1875,12 +2198,93 @@ export class PageAnalyzer {
1875
2198
  String(item.attributes?.placeholder ?? ""),
1876
2199
  String(item.attributes?.name ?? ""),
1877
2200
  String(item.attributes?.id ?? ""),
2201
+ String(item.attributes?._containerTitle ?? ""),
2202
+ String(item.attributes?._containerCtaStyle ?? ""),
1878
2203
  item.href || "",
1879
2204
  item.selector,
1880
2205
  ]
1881
2206
  .join(" ")
1882
2207
  .toLowerCase();
1883
2208
  }
2209
+ selectRepresentativeItems(items) {
2210
+ const groups = new Map();
2211
+ const passthrough = [];
2212
+ for (const item of items) {
2213
+ const containerFingerprint = String(item.attributes?._containerFingerprint ?? "").trim();
2214
+ if (!containerFingerprint) {
2215
+ passthrough.push(item);
2216
+ continue;
2217
+ }
2218
+ const key = `${containerFingerprint}|${this.representativeActionStyle(item)}`;
2219
+ const bucket = groups.get(key) ?? [];
2220
+ bucket.push(item);
2221
+ groups.set(key, bucket);
2222
+ }
2223
+ const representatives = Array.from(groups.values()).map(group => this.pickRepresentativeItem(group));
2224
+ return [...passthrough, ...representatives];
2225
+ }
2226
+ representativeActionStyle(item) {
2227
+ const text = this.semanticText(item);
2228
+ const sourceHints = String(item.attributes?._sourceHints ?? "").toLowerCase();
2229
+ const containerTitle = String(item.attributes?._containerTitle ?? "").toLowerCase();
2230
+ if (sourceHints.includes("promoted-target") ||
2231
+ sourceHints.includes("cursor-pointer")) {
2232
+ return "tile-click";
2233
+ }
2234
+ if (item.actionKind === "navigate" &&
2235
+ containerTitle &&
2236
+ (text.includes(containerTitle) ||
2237
+ (item.href && !this.isCheckoutItem(item)))) {
2238
+ return "tile-click";
2239
+ }
2240
+ if (this.isAddToCartItem(item))
2241
+ return "cta:add-to-cart";
2242
+ if (/\blogin for pricing\b/.test(text))
2243
+ return "cta:login-for-pricing";
2244
+ if (/\bselect options\b/.test(text))
2245
+ return "cta:select-options";
2246
+ if (this.isCheckoutItem(item))
2247
+ return "cta:checkout";
2248
+ if (this.isRemoveItemAction(item))
2249
+ return "cta:remove";
2250
+ if (item.actionKind === "navigate")
2251
+ return "navigate";
2252
+ if (item.actionKind === "select")
2253
+ return "select";
2254
+ if (item.actionKind === "fill")
2255
+ return "fill";
2256
+ return `${item.actionKind}:${text
2257
+ .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/g, "email")
2258
+ .replace(/\$\s?\d[\d,.]*/g, "price")
2259
+ .replace(/\d+/g, "n")
2260
+ .slice(0, 80)}`;
2261
+ }
2262
+ pickRepresentativeItem(items) {
2263
+ return [...items].sort((left, right) => {
2264
+ const leftScore = this.representativePriority(left);
2265
+ const rightScore = this.representativePriority(right);
2266
+ if (leftScore !== rightScore)
2267
+ return rightScore - leftScore;
2268
+ return (left.selector?.length ?? 0) - (right.selector?.length ?? 0);
2269
+ })[0];
2270
+ }
2271
+ representativePriority(item) {
2272
+ let score = 0;
2273
+ const sourceHints = String(item.attributes?._sourceHints ?? "").toLowerCase();
2274
+ if (sourceHints.includes("promoted-target"))
2275
+ score += 4;
2276
+ if (sourceHints.includes("cursor-pointer"))
2277
+ score += 3;
2278
+ if (item.actionKind === "navigate")
2279
+ score += 2;
2280
+ if (item.actionKind === "click")
2281
+ score += 1;
2282
+ if (String(item.attributes?._testId ?? ""))
2283
+ score += 1;
2284
+ if (item.accessibleName)
2285
+ score += 1;
2286
+ return score;
2287
+ }
1884
2288
  isAddToCartItem(item) {
1885
2289
  return /\b(add to cart|add to bag|buy now|add item)\b/.test(this.semanticText(item));
1886
2290
  }
@@ -1888,11 +2292,36 @@ export class PageAnalyzer {
1888
2292
  return /\b(checkout|proceed to checkout|submit order|place order)\b/.test(this.semanticText(item));
1889
2293
  }
1890
2294
  isRemoveItemAction(item) {
1891
- return /\b(remove|delete|trash|clear item|remove item)\b/.test(this.semanticText(item));
2295
+ return /\b(remove|delete|trash|clear item|remove item|dismiss|archive|hide item|close item)\b/.test(this.semanticText(item));
2296
+ }
2297
+ estimateCollectionCount(html) {
2298
+ const tableRows = (html.match(/<tr\b/gi) ?? []).length;
2299
+ if (tableRows > 0)
2300
+ return tableRows;
2301
+ const listItems = (html.match(/<li\b/gi) ?? []).length;
2302
+ if (listItems > 0)
2303
+ return listItems;
2304
+ const cards = (html.match(/<(?:article|section|div)\b[^>]*(?:product|card|result|item|row)[^>]*>/gi) ?? []).length;
2305
+ return cards;
1892
2306
  }
1893
2307
  isAuthEntryItem(item) {
1894
2308
  return /\b(sign up|register|create account|sign in|log in|login)\b/.test(this.semanticText(item));
1895
2309
  }
2310
+ isProtectedActionItem(item) {
2311
+ return /\b(checkout|pricing|view price|account|settings|billing|subscription|dashboard|admin|manage account|saved items|favorites|download)\b/.test(this.semanticText(item));
2312
+ }
2313
+ isLogoutAction(item) {
2314
+ return /\b(log out|logout|sign out)\b/.test(this.semanticText(item));
2315
+ }
2316
+ isRetryAction(item) {
2317
+ return /\b(retry|try again|resend|send again|reload|refresh|reconnect|resume)\b/.test(this.semanticText(item));
2318
+ }
2319
+ isCreateCollectionAction(item) {
2320
+ const text = this.semanticText(item);
2321
+ if (this.isAddToCartItem(item))
2322
+ return false;
2323
+ return /\b(add row|add record|add item|new row|new record|create item|create record|add another|new item)\b/.test(text);
2324
+ }
1896
2325
  isMediaOpenItem(item) {
1897
2326
  const text = this.semanticText(item);
1898
2327
  return (item.tagName === "VIDEO" ||
@@ -1906,6 +2335,16 @@ export class PageAnalyzer {
1906
2335
  isQuantityAction(item) {
1907
2336
  return /\b(qty|quantity|increase|decrease|increment|decrement|plus|minus)\b/.test(this.semanticText(item));
1908
2337
  }
2338
+ inferQuantityDelta(item) {
2339
+ const text = this.semanticText(item);
2340
+ if (/\b(decrease|decrement|minus|remove one|lower qty|lower quantity)\b/.test(text)) {
2341
+ return -1;
2342
+ }
2343
+ if (/\b(increase|increment|plus|add one|raise qty|raise quantity)\b/.test(text)) {
2344
+ return 1;
2345
+ }
2346
+ return undefined;
2347
+ }
1909
2348
  isFilterAction(item) {
1910
2349
  return /\b(filter|refine|apply filter|show results|category|brand|size|color|price)\b/.test(this.semanticText(item));
1911
2350
  }