@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.
- package/dist/analyzer/page-analyzer.d.ts +16 -0
- package/dist/analyzer/page-analyzer.d.ts.map +1 -1
- package/dist/analyzer/page-analyzer.js +495 -56
- package/dist/analyzer/page-analyzer.js.map +1 -1
- package/dist/browser/dom-snapshot.d.ts.map +1 -1
- package/dist/browser/dom-snapshot.js +84 -0
- package/dist/browser/dom-snapshot.js.map +1 -1
- package/dist/expertise/tester/commerce-checks.d.ts.map +1 -1
- package/dist/expertise/tester/commerce-checks.js +79 -0
- package/dist/expertise/tester/commerce-checks.js.map +1 -1
- package/dist/expertise/tester/dialog-feedback-checks.d.ts.map +1 -1
- package/dist/expertise/tester/dialog-feedback-checks.js +27 -10
- package/dist/expertise/tester/dialog-feedback-checks.js.map +1 -1
- package/dist/expertise/tester/form-checks.d.ts +6 -0
- package/dist/expertise/tester/form-checks.d.ts.map +1 -1
- package/dist/expertise/tester/form-checks.js +50 -0
- package/dist/expertise/tester/form-checks.js.map +1 -1
- package/dist/expertise/tester/keyboard-disclosure-checks.d.ts +4 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.d.ts.map +1 -1
- package/dist/expertise/tester/keyboard-disclosure-checks.js +30 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.js.map +1 -1
- package/dist/expertise/tester/list-workflow-checks.js +12 -2
- package/dist/expertise/tester/list-workflow-checks.js.map +1 -1
- package/dist/expertise/tester/search-checks.d.ts.map +1 -1
- package/dist/expertise/tester/search-checks.js +23 -0
- package/dist/expertise/tester/search-checks.js.map +1 -1
- package/dist/expertise/tester/variant-checks.d.ts.map +1 -1
- package/dist/expertise/tester/variant-checks.js +36 -0
- package/dist/expertise/tester/variant-checks.js.map +1 -1
- package/dist/expertise/tester-expertise.d.ts.map +1 -1
- package/dist/expertise/tester-expertise.js +8 -2
- package/dist/expertise/tester-expertise.js.map +1 -1
- package/dist/extractors/helpers.d.ts.map +1 -1
- package/dist/extractors/helpers.js +3 -0
- package/dist/extractors/helpers.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
this.
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
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
|
}
|