From 02ac8dbdbbf2c36acb965f8fe22fc7c4ddaed795 Mon Sep 17 00:00:00 2001 From: Manolescu Razvan Date: Wed, 19 Nov 2025 17:53:38 +0200 Subject: [PATCH] feat: [SITES-36263] Spacecat A11y Autofix pipeline integration --- package-lock.json | 68 -- .../codefix-handler.js | 243 +------ .../mystique-data-processing.js | 118 +-- src/accessibility/utils/constants.js | 16 + .../generate-individual-opportunities.js | 216 ++++-- src/common/codefix-handler.js | 334 +++++++++ src/common/codefix-response-handler.js | 107 +++ src/common/index.js | 3 + .../oppty-handlers/accessibility-handler.js | 4 +- src/forms-opportunities/utils.js | 94 +++ src/index.js | 2 + .../codefix-handler.test.js | 669 +++++++++++++++--- .../generate-individual-opportunities.test.js | 411 +++++++++-- .../mystique-aggregation-e2e.test.js | 586 +++++++++++++++ .../mystique-aggregation.test.js | 391 ++++++++++ .../mystique-data-processing.test.js | 16 +- .../forms/accessibility-handler.test.js | 60 +- test/audits/forms/utils.test.js | 413 +++++++++++ 18 files changed, 3169 insertions(+), 582 deletions(-) create mode 100644 src/common/codefix-handler.js create mode 100644 src/common/codefix-response-handler.js create mode 100644 test/audits/accessibility/mystique-aggregation-e2e.test.js create mode 100644 test/audits/accessibility/mystique-aggregation.test.js diff --git a/package-lock.json b/package-lock.json index 269391015..afce26cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1006,7 +1006,6 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.3.0.tgz", "integrity": "sha512-1eKFpKZMNamJHhq6eFm9gMLhgQunsf34mEFbaqg9ChEXZYk18SYgUu5GeNTvzk5Rzo0h9AuSwLtnI2Up2OSiSA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -4306,7 +4305,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4944,7 +4942,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -6699,7 +6696,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -7337,7 +7333,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -7724,7 +7719,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.716.0.tgz", "integrity": "sha512-lA4IB9FzR2KjH7EVCo+mHGFKqdViVyeBQEIX9oVratL/l7P0bMS1fMwgfHOc3ACazqNxBxDES7x08ZCp32y6Lw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -7778,7 +7772,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.716.0.tgz", "integrity": "sha512-i4SVNsrdXudp8T4bkm7Fi3YWlRnvXCSwvNDqf6nLqSJxqr4CN3VlBELueDyjBK7TAt453/qSif+eNx+bHmwo4Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -9041,7 +9034,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.716.0.tgz", "integrity": "sha512-lA4IB9FzR2KjH7EVCo+mHGFKqdViVyeBQEIX9oVratL/l7P0bMS1fMwgfHOc3ACazqNxBxDES7x08ZCp32y6Lw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -9095,7 +9087,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.716.0.tgz", "integrity": "sha512-i4SVNsrdXudp8T4bkm7Fi3YWlRnvXCSwvNDqf6nLqSJxqr4CN3VlBELueDyjBK7TAt453/qSif+eNx+bHmwo4Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -10441,7 +10432,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -10495,7 +10485,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -11340,7 +11329,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.721.0.tgz", "integrity": "sha512-TGENpPbk6xtbLH07XZVZlhmK+SLs3stHLIQ/lZXZ8stZKT9//kA19P1E5+LNCmJFSLNxSj5ziHFOv/CzQN9U9g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -13360,7 +13348,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.721.0.tgz", "integrity": "sha512-1Pv8F02hQFmPZs7WtGfQNlnInbG1lLzyngJc/MlZ3Ld2fIoWjaWp7bJWgYAjnzHNEuDtCabWJvIfePdRqsbYoA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -15639,7 +15626,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -15888,7 +15874,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -19135,7 +19120,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -20884,7 +20868,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -21860,7 +21843,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -24183,7 +24165,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -24280,7 +24261,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.917.0.tgz", "integrity": "sha512-PPOyDwlg59ESbj/Ur8VKRvlW6GRViThykNCg5qjCuejiEQ8F1j+0yPxIa+H0x6iklDZF/+AiERtLpmZh3UjD0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -24554,7 +24534,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -25199,7 +25178,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -26242,7 +26220,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.3.tgz", "integrity": "sha512-bltsLAr4juMJJ2tT5/L/CtwUGIvHihtPe6SO/z3jjOD73PHhOYxcuwCMFFyTbTy5S4WThJO32oZk7r+pg3ZoCQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -28001,7 +27978,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -28124,7 +28100,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -28969,7 +28944,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -29647,7 +29621,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -31028,7 +31001,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.3.tgz", "integrity": "sha512-bltsLAr4juMJJ2tT5/L/CtwUGIvHihtPe6SO/z3jjOD73PHhOYxcuwCMFFyTbTy5S4WThJO32oZk7r+pg3ZoCQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -31254,7 +31226,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -34447,7 +34418,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.3.tgz", "integrity": "sha512-bltsLAr4juMJJ2tT5/L/CtwUGIvHihtPe6SO/z3jjOD73PHhOYxcuwCMFFyTbTy5S4WThJO32oZk7r+pg3ZoCQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -35441,7 +35411,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.917.0.tgz", "integrity": "sha512-PPOyDwlg59ESbj/Ur8VKRvlW6GRViThykNCg5qjCuejiEQ8F1j+0yPxIa+H0x6iklDZF/+AiERtLpmZh3UjD0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -35715,7 +35684,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -36360,7 +36328,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -37403,7 +37370,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -39295,7 +39261,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -39933,7 +39898,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -40883,7 +40847,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.917.0.tgz", "integrity": "sha512-PPOyDwlg59ESbj/Ur8VKRvlW6GRViThykNCg5qjCuejiEQ8F1j+0yPxIa+H0x6iklDZF/+AiERtLpmZh3UjD0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -41749,7 +41712,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -42439,7 +42401,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -44181,7 +44142,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -44819,7 +44779,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -46460,7 +46419,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -47076,7 +47034,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -48004,7 +47961,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.932.0.tgz", "integrity": "sha512-ryrbPLPtIDHdVTjGSVQmKpN40JaCckw3T8pW2XU+Wu/Q2E3xDegfc51o194S8i+NGAK7tfstiKmxa5uqmQ1YyA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -49938,7 +49894,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.721.0.tgz", "integrity": "sha512-jwsgdUEbNJqs1O0AQtf9M6SI7hFIjxH+IKeKCMca0xVt+Tr1UqLr/qMK/6W8LoMtRFnE0lpBSHW6hvmLp2OCoQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -51086,7 +51041,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.758.0.tgz", "integrity": "sha512-ue9hbzjWNQmmyoSeWDRPwnYddsD3BVao5mSFA1kXFNVqWPEenjpkZ1xAlBVzHMMNoEz7LvGI+onXIHntNyiOLQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -53398,7 +53352,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -53807,7 +53760,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -53830,7 +53782,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -55228,7 +55179,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -55406,7 +55356,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -57897,7 +57846,6 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -58329,7 +58277,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -58376,7 +58323,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -58841,7 +58787,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.11.0.tgz", "integrity": "sha512-b7RRs3/twrsCxb113ZgycyaYcXJUQADFMKTiAfzRJu/2hBD2UZkyrjrh8BNTwQ5PUJJmHLoapv1uhpJFk3qKvQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@aws/lambda-invoke-store": "^0.0.1", @@ -59245,7 +59190,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -59502,7 +59446,6 @@ "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -61750,7 +61693,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -66502,7 +66444,6 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -67581,7 +67522,6 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -70601,7 +70541,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -72223,7 +72162,6 @@ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -72234,7 +72172,6 @@ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -73042,7 +72979,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -74471,7 +74407,6 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -75165,7 +75100,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -75538,7 +75472,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -75637,7 +75570,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", diff --git a/src/accessibility/auto-optimization-handlers/codefix-handler.js b/src/accessibility/auto-optimization-handlers/codefix-handler.js index 1e3ba1519..5f4f60c16 100644 --- a/src/accessibility/auto-optimization-handlers/codefix-handler.js +++ b/src/accessibility/auto-optimization-handlers/codefix-handler.js @@ -10,245 +10,14 @@ * governing permissions and limitations under the License. */ -import { createHash } from 'crypto'; -import { - ok, badRequest, notFound, internalServerError, -} from '@adobe/spacecat-shared-http-utils'; -import { isNonEmptyArray } from '@adobe/spacecat-shared-utils'; -import { getObjectFromKey } from '../../utils/s3-utils.js'; - -/** - * Generates a hash for the given URL and source combination. - * @param {string} url - The URL to hash - * @param {string} source - The source to hash - * @returns {string} - The generated hash (first 16 characters of MD5) - */ -function generateUrlSourceHash(url, source) { - const combined = `${url}_${source}`; - return createHash('md5').update(combined).digest('hex').substring(0, 16); -} - -/** - * Reads code change report from S3 bucket - * @param {Object} s3Client - The S3 client instance - * @param {string} bucketName - The S3 bucket name - * @param {string} siteId - The site ID - * @param {string} url - The page URL - * @param {string} source - The source (optional) - * @param {string} type - The issue type (e.g., 'color-contrast') - * @param {Object} log - Logger instance - * @returns {Promise} - The report data or null if not found - */ -async function readCodeChangeReport(s3Client, bucketName, siteId, url, source, type, log) { - try { - const urlSourceHash = generateUrlSourceHash(url, source || ''); - const reportKey = `fixes/${siteId}/${urlSourceHash}/${type}/report.json`; - - log.info(`Reading code change report from S3: ${reportKey}`); - - const reportData = await getObjectFromKey(s3Client, bucketName, reportKey, log); - - if (!reportData) { - log.warn(`No code change report found for key: ${reportKey}`); - return null; - } - - log.info(`Successfully read code change report from S3: ${reportKey}`); - return reportData; - } catch (error) { - log.error(`Error reading code change report from S3: ${error.message}`, error); - return null; - } -} - -/** - * Updates suggestions with code change data - * @param {Array} suggestions - Array of suggestion objects - * @param {string} url - The page URL to match - * @param {string} source - The source to match (optional) - * @param {string} ruleId - The WCAG rule ID to match - * @param {Object} reportData - The code change report data - * @param {Object} log - Logger instance - * @returns {Promise} - Array of updated suggestions - */ -async function updateSuggestionsWithCodeChange(suggestions, url, source, ruleId, reportData, log) { - const updatedSuggestions = []; - - try { - const promises = []; - for (const suggestion of suggestions) { - const suggestionData = suggestion.getData(); - - // Check if this suggestion matches the criteria - const suggestionUrl = suggestionData.url; - const suggestionSource = suggestionData.source; - const suggestionRuleId = suggestionData.issues[0]?.type; - - if (suggestionUrl === url - && (!source || suggestionSource === source) - && suggestionRuleId === ruleId - && !!reportData.diff) { - log.info(`Updating suggestion ${suggestion.getId()} with code change data`); - - // Update suggestion data with diff content and availability flag - const updatedData = { - ...suggestionData, - patchContent: reportData.diff, - isCodeChangeAvailable: true, - }; - - suggestion.setData(updatedData); - suggestion.setUpdatedBy('system'); - - promises.push(suggestion.save()); - updatedSuggestions.push(suggestion); - - log.info(`Successfully updated suggestion ${suggestion.getId()}`); - } - } - await Promise.all(promises); - } catch (error) { - log.error(`Error updating suggestions with code change data: ${error.message}`, error); - throw error; - } - - return updatedSuggestions; -} - /** - * AccessibilityCodeFixHandler - Updates suggestions with code changes from S3 + * Forms Accessibility Code Fix Handler * - * Expected message format: - * { - * "siteId": "", - * "type": "codefix:accessibility", - * "data": { - * "opportunityId": "", - * "updates": [ - * { - * "url": "", - * "source": "", // optional - * "type": ["color-contrast", "select-name"] - * } - * ] - * } - * } + * This is a legacy entry point that now delegates to the common code fix response handler. + * Kept for backward compatibility with existing message routing. * - * @param {Object} message - The SQS message - * @param {Object} context - The context object containing dataAccess, log, s3Client, etc. - * @returns {Promise} - HTTP response + * @deprecated Use the common codeFixResponseHandler directly */ -export default async function accessibilityCodeFixHandler(message, context) { - const { - log, dataAccess, s3Client, env, - } = context; - const { Opportunity } = dataAccess; - const { siteId, data } = message; - - if (!data) { - log.error('AccessibilityCodeFixHandler: No data provided in message'); - return badRequest('No data provided in message'); - } - - const { opportunityId, updates } = data; - - if (!opportunityId) { - log.error('[AccessibilityCodeFixHandler] No opportunityId provided'); - return badRequest('No opportunityId provided'); - } - - if (!isNonEmptyArray(updates)) { - log.error('[AccessibilityCodeFixHandler] No updates provided or updates is empty'); - return badRequest('No updates provided or updates is empty'); - } - - log.info(`[AccessibilityCodeFixHandler] Processing message for siteId: ${siteId}, opportunityId: ${opportunityId}`); - - try { - // Find the opportunity - const opportunity = await Opportunity.findById(opportunityId); - - if (!opportunity) { - log.error(`[AccessibilityCodeFixHandler] Opportunity not found for ID: ${opportunityId}`); - return notFound('Opportunity not found'); - } - - // Verify the opportunity belongs to the correct site - if (opportunity.getSiteId() !== siteId) { - const errorMsg = `[AccessibilityCodeFixHandler] Site ID mismatch. Expected: ${siteId}, Found: ${opportunity.getSiteId()}`; - log.error(errorMsg); - return badRequest('Site ID mismatch'); - } - - // Get all suggestions for the opportunity - const suggestions = await opportunity.getSuggestions(); - - if (!isNonEmptyArray(suggestions)) { - log.warn(`[AccessibilityCodeFixHandler] No suggestions found for opportunity: ${opportunityId}`); - return ok('No suggestions found for opportunity'); - } - - const bucketName = env.S3_MYSTIQUE_BUCKET_NAME; - - if (!bucketName) { - log.error('AccessibilityCodeFixHandler: S3_MYSTIQUE_BUCKET_NAME environment variable not set'); - return internalServerError('S3 bucket name not configured'); - } - - let totalUpdatedSuggestions = 0; - - // Process each update - await Promise.all(updates.map(async (update) => { - const { url, source, type: types } = update; - - if (!url) { - log.warn('[AccessibilityCodeFixHandler] Skipping update without URL'); - return; - } - - if (!isNonEmptyArray(types)) { - log.warn(`[AccessibilityCodeFixHandler] Skipping update for URL ${url} without types`); - return; - } - - log.info(`[AccessibilityCodeFixHandler] Processing update for URL: ${url}, source: ${source || 'N/A'}, types: ${types.join(', ')}`); - - // For each type in the update, try to read the code change report - await Promise.all(types.map(async (ruleId) => { - let reportData = await readCodeChangeReport( - s3Client, - bucketName, - siteId, - url, - source, - ruleId, - log, - ); - - if (!reportData) { - log.warn(`[AccessibilityCodeFixHandler] No code change report found for URL: ${url}, source: ${source}, type: ${ruleId}`); - return; - } - - reportData = JSON.parse(reportData); - - // Update matching suggestions with the code change data - const updatedSuggestions = await updateSuggestionsWithCodeChange( - suggestions, - url, - source, - ruleId, - reportData, - log, - ); - totalUpdatedSuggestions += updatedSuggestions.length; - })); - })); +import codeFixResponseHandler from '../../common/codefix-response-handler.js'; - log.info(`[AccessibilityCodeFixHandler] Successfully processed all updates. Total suggestions updated: ${totalUpdatedSuggestions}`); - return ok(); - } catch (error) { - log.error(`[AccessibilityCodeFixHandler] Error processing message: ${error.message}`, error); - return internalServerError(`Error processing message: ${error.message}`); - } -} +export default codeFixResponseHandler; diff --git a/src/accessibility/guidance-utils/mystique-data-processing.js b/src/accessibility/guidance-utils/mystique-data-processing.js index f15a672ad..e9c3a1783 100644 --- a/src/accessibility/guidance-utils/mystique-data-processing.js +++ b/src/accessibility/guidance-utils/mystique-data-processing.js @@ -10,73 +10,89 @@ * governing permissions and limitations under the License. */ -import { isNonEmptyArray, isNonEmptyObject } from '@adobe/spacecat-shared-utils'; +import { isNonEmptyArray, isNonEmptyObject, buildAggregationKey } from '@adobe/spacecat-shared-utils'; import { Suggestion as SuggestionDataAccess } from '@adobe/spacecat-shared-data-access'; import { issueTypesForMystique } from '../utils/constants.js'; /** * Processes suggestions directly to create Mystique message data * + * Supports multiple aggregation strategies: + * - PER_ELEMENT: Each suggestion has one issue with one htmlWithIssues element + * - PER_ISSUE_TYPE_PER_PAGE: Each suggestion has one issue with multiple htmlWithIssues elements + * - PER_PAGE: Each suggestion has multiple issues with multiple htmlWithIssues elements + * * @param {Array} suggestions - Array of suggestion objects from the opportunity * @returns {Array} Array of message data objects ready for SQS sending */ export function processSuggestionsForMystique(suggestions) { - if (!suggestions || !Array.isArray(suggestions)) { + if (!Array.isArray(suggestions) || suggestions.length === 0) { return []; } - // Group suggestions by url - const suggestionsByUrl = {}; - for (const suggestion of suggestions) { - const suggestionData = suggestion.getData(); + const SKIPPED_STATUSES = [ + SuggestionDataAccess.STATUSES.FIXED, + SuggestionDataAccess.STATUSES.SKIPPED, + ]; + + // Helper: Extract issue items from a suggestion that need Mystique processing + const extractIssueItems = (suggestion) => { + const data = suggestion.getData(); const suggestionId = suggestion.getId(); - // skip sending to M suggestions that are fixed or skipped - if (![SuggestionDataAccess.STATUSES.FIXED, SuggestionDataAccess.STATUSES.SKIPPED] - .includes(suggestion.getStatus()) - && suggestionData.issues - && isNonEmptyArray(suggestionData.issues) - && isNonEmptyArray(suggestionData.issues[0].htmlWithIssues)) { - // Starting with SITES-33832, a suggestion corresponds to a single granular issue, - // i.e. target selector and faulty HTML line - const singleIssue = suggestionData.issues[0]; - const singleHtmlWithIssue = singleIssue.htmlWithIssues[0]; - // skip sending to M suggestions that already have guidance - if (issueTypesForMystique.includes(singleIssue.type) - && !isNonEmptyObject(singleHtmlWithIssue.guidance)) { - const { url } = suggestionData; - if (!suggestionsByUrl[url]) { - suggestionsByUrl[url] = []; - } - suggestionsByUrl[url].push({ ...suggestionData, suggestionId }); - } + + if (!isNonEmptyArray(data.issues)) { + return []; } - } - const messageData = []; - for (const [url, suggestionsForUrl] of Object.entries(suggestionsByUrl)) { - const issuesList = []; - for (const suggestion of suggestionsForUrl) { - if (isNonEmptyArray(suggestion.issues)) { - // Starting with SITES-33832, a suggestion corresponds to a single granular issue, - // i.e. target selector and faulty HTML line - const singleIssue = suggestion.issues[0]; - if (isNonEmptyArray(singleIssue.htmlWithIssues)) { - const singleHtmlWithIssue = singleIssue.htmlWithIssues[0]; - issuesList.push({ - issueName: singleIssue.type, - faultyLine: singleHtmlWithIssue.update_from || singleHtmlWithIssue.updateFrom || '', - targetSelector: singleHtmlWithIssue.target_selector || singleHtmlWithIssue.targetSelector || '', - issueDescription: singleIssue.description || '', - suggestionId: suggestion.suggestionId, - }); - } - } + return data.issues + .filter((issue) => issueTypesForMystique.includes(issue.type)) + .filter((issue) => isNonEmptyArray(issue.htmlWithIssues)) + .flatMap((issue) => issue.htmlWithIssues + .filter((html) => !isNonEmptyObject(html.guidance)) + .map((html) => { + // Build aggregation key based on granularity strategy for this issue type + const targetSelector = html.target_selector || html.targetSelector || ''; + const aggregationKey = buildAggregationKey( + issue.type, + data.url, + targetSelector, + data.source, + ); + + return { + issueName: issue.type, + faultyLine: html.update_from || html.updateFrom || '', + targetSelector, + issueDescription: issue.description || '', + suggestionId, + url: data.url, + aggregationKey, + }; + })); + }; + + // Process all suggestions and extract issue items + const allIssueItems = suggestions + .filter((suggestion) => !SKIPPED_STATUSES.includes(suggestion.getStatus())) + .flatMap(extractIssueItems); + + // Group by aggregation key + const byAggregationKey = allIssueItems.reduce((acc, item) => { + const { aggregationKey, ...issueItem } = item; + if (!acc[aggregationKey]) { + acc[aggregationKey] = { + url: item.url, + issuesList: [], + }; } - messageData.push({ - url, - issuesList, - }); - } + acc[aggregationKey].issuesList.push(issueItem); + return acc; + }, {}); - return messageData; + // Convert to final format + return Object.entries(byAggregationKey).map(([aggregationKey, data]) => ({ + url: data.url, + aggregationKey, + issuesList: data.issuesList, + })); } diff --git a/src/accessibility/utils/constants.js b/src/accessibility/utils/constants.js index 2afa9185a..887908530 100644 --- a/src/accessibility/utils/constants.js +++ b/src/accessibility/utils/constants.js @@ -732,6 +732,22 @@ export const accessibilityOpportunitiesMap = { ], }; +/** + * Accessibility issue types that should be sent to Mystique for automatic code fix + */ +export const issueTypesForCodeFix = [ + 'aria-allowed-attr', + 'aria-prohibited-attr', + 'aria-roles', + 'aria-hidden-focus', + 'aria-required-attr', + 'aria-valid-attr-value', + 'button-name', + 'link-name', + 'select-name', + 'aria-required-parent', +]; + /** * Accessibility issue types that should be sent to Mystique for remediation guidance */ diff --git a/src/accessibility/utils/generate-individual-opportunities.js b/src/accessibility/utils/generate-individual-opportunities.js index cd6c0a5fb..bd976112d 100644 --- a/src/accessibility/utils/generate-individual-opportunities.js +++ b/src/accessibility/utils/generate-individual-opportunities.js @@ -10,14 +10,16 @@ * governing permissions and limitations under the License. */ -import { isNonEmptyArray, isString } from '@adobe/spacecat-shared-utils'; +import { isNonEmptyArray, isString, buildSuggestionKey } from '@adobe/spacecat-shared-utils'; import { Opportunity as OpportunityDataAccess, Suggestion as SuggestionDataAccess } from '@adobe/spacecat-shared-data-access'; import { createAccessibilityAssistiveOpportunity, createAccessibilityColorContrastOpportunity } from './report-oppty.js'; import { syncSuggestions, keepSameDataFunction, } from '../../utils/data-access.js'; -import { successCriteriaLinks, accessibilityOpportunitiesMap, URL_SOURCE_SEPARATOR } from './constants.js'; +import { + successCriteriaLinks, accessibilityOpportunitiesMap, URL_SOURCE_SEPARATOR, issueTypesForCodeFix, +} from './constants.js'; import { getAuditData } from './data-processing.js'; import { processSuggestionsForMystique } from '../guidance-utils/mystique-data-processing.js'; import { isAuditEnabledForSite } from '../../common/audit-utils.js'; @@ -53,10 +55,61 @@ function extractSourceFromUrl(url) { * @param {string} params.deliveryType - Delivery type * @returns {Object} The message object ready for SQS */ -function createMystiqueMessage({ +/** + * Creates a message to send directly to Mystique (legacy flow) + * Used for issue types not in the code fix list + * @param {Object} params - Message parameters + * @param {string} params.url - The page URL + * @param {Array} params.issuesList - List of accessibility issues + * @param {Object} params.opportunity - The opportunity object + * @param {string} params.siteId - Site identifier + * @param {string} params.auditId - Audit identifier + * @param {string} params.deliveryType - Delivery type + * @returns {Object} The message to send directly to Mystique + */ +function createDirectMystiqueMessage({ + url, + issuesList, + opportunity, + siteId, + auditId, + deliveryType, + aggregationKey, +}) { + return { + type: 'guidance:accessibility-remediation', + siteId: siteId || '', + auditId: auditId || '', + deliveryType, + time: new Date().toISOString(), + aggregationKey, + data: { + url, + opportunityId: opportunity.getId(), + issuesList, + }, + }; +} + +/** + * Creates a message payload to be forwarded to Mystique for accessibility remediation + * with code injection (new flow via import worker) + * Note: codeBucket and codePath will be added by spacecat-import-worker based on siteId + * @param {Object} params - Message parameters + * @param {string} params.url - The page URL + * @param {Array} params.issuesList - List of accessibility issues + * @param {Object} params.opportunity - The opportunity object + * @param {string} params.aggregationKey - Aggregation key that identifies suggestion group + * @param {string} params.siteId - Site identifier + * @param {string} params.auditId - Audit identifier + * @param {string} params.deliveryType - Delivery type + * @returns {Object} The message payload for import worker to forward + */ +function createMystiqueForwardPayload({ url, issuesList, opportunity, + aggregationKey, siteId, auditId, deliveryType, @@ -67,6 +120,7 @@ function createMystiqueMessage({ auditId: auditId || '', deliveryType, time: new Date().toISOString(), + aggregationKey, data: { url, opportunityId: opportunity.getId(), @@ -76,7 +130,20 @@ function createMystiqueMessage({ } /** - * Sends a single message to Mystique for a specific issue type + * Determines if all issues in the list should use the code fix flow + * @param {Array} issuesList - List of issues + * @returns {boolean} True if all issues should get code fix + */ +function shouldUseCodeFixFlow(issuesList) { + if (!isNonEmptyArray(issuesList)) { + return false; + } + // Check if all issues in the list are in the code fix types + return issuesList.every((issue) => issueTypesForCodeFix.includes(issue.issueName)); +} + +/** + * Sends a message to Mystique for accessibility remediation directly or via import worker * * @param {Object} params - Parameters for sending the message * @param {Object} params.suggestion - The suggestion object @@ -90,6 +157,7 @@ function createMystiqueMessage({ * @param {Object} params.sqs - SQS client * @param {Object} params.env - Environment variables * @param {Object} params.log - Logger instance + * @param {Object} params.context - Audit context * @returns {Promise} Result object with success status and details */ async function sendMystiqueMessage({ @@ -99,37 +167,87 @@ async function sendMystiqueMessage({ siteId, auditId, deliveryType, + aggregationKey, sqs, env, log, + context, }) { - const message = createMystiqueMessage({ - url, - issuesList, - opportunity, - siteId, - auditId, - deliveryType, - }); + // Check if code fix flow is enabled for this site + const autoFixEnabled = await isAuditEnabledForSite('a11y-mystique-auto-fix', context.site, context); + const useCodeFixFlow = autoFixEnabled && shouldUseCodeFixFlow(issuesList); - try { - await sqs.sendMessage(env.QUEUE_SPACECAT_TO_MYSTIQUE, message); - log.info( - `[A11yIndividual] Sent message to Mystique for url ${url}: ${JSON.stringify(message, null, 2)}`, - ); - return { - success: true, + if (useCodeFixFlow) { + const forwardPayload = createMystiqueForwardPayload({ url, + issuesList, + opportunity, + aggregationKey, + siteId, + auditId, + deliveryType, + }); + + const message = { + type: 'code', + siteId, + allowCache: true, + data: {}, + forward: { + queue: env.QUEUE_SPACECAT_TO_MYSTIQUE, + payload: forwardPayload, + }, }; - } catch (error) { - log.error( - `[A11yIndividual][A11yProcessingError] Failed to send message to Mystique for url ${url}, message: ${JSON.stringify(message, null, 2)} with error: ${error.message}`, - ); - return { - success: false, + + try { + await sqs.sendMessage(env.IMPORT_WORKER_QUEUE_URL, message); + log.info( + `[A11yIndividual] Sent message to import worker for code fix and forwarding to Mystique for url ${url}`, + ); + return { + success: true, + url, + }; + } catch (error) { + log.error( + `[A11yIndividual][A11yProcessingError] Failed to send message to import worker for url ${url} with error: ${error.message}`, + ); + return { + success: false, + url, + error: error.message, + }; + } + } else { + // Legacy flow: Send directly to Mystique without code injection + const message = createDirectMystiqueMessage({ url, - error: error.message, - }; + issuesList, + opportunity, + siteId, + auditId, + deliveryType, + }); + + try { + await sqs.sendMessage(env.QUEUE_SPACECAT_TO_MYSTIQUE, message); + log.info( + `[A11yIndividual] Sent message directly to Mystique (legacy flow) for url ${url}`, + ); + return { + success: true, + url, + }; + } catch (error) { + log.error( + `[A11yIndividual][A11yProcessingError] Failed to send message to Mystique for url ${url}, message: ${JSON.stringify(message, null, 2)} with error: ${error.message}`, + ); + return { + success: false, + url, + error: error.message, + }; + } } } @@ -261,12 +379,15 @@ export function formatIssue(type, issueData, severity) { * Only includes issues that are in our tracked categories (from accessibilityOpportunitiesMap) * and only includes URLs that have at least one issue. * + * Uses aggregation strategies to control how HTML elements are grouped into suggestions. + * * @param {Object} accessibilityData - The accessibility data to process * @param {Object} accessibilityData.overall - Site-wide summary data * @param {Object} accessibilityData[url] - Per-URL accessibility data + * @param {Object} opportunitiesMap - Map of opportunity types to issue types * @returns {Object} Object with data array containing URLs and their issues */ -export function aggregateAccessibilityIssues( +export function aggregateA11yIssuesByOppType( accessibilityData, opportunitiesMap = accessibilityOpportunitiesMap, ) { @@ -398,26 +519,14 @@ export async function createIndividualOpportunitySuggestions( context, log, ) { - // Build unique key for each suggestion based on URL, - // issue type and target - single issue per data item - const buildKey = (data) => { - const issues = data.issues || []; - if (issues.length === 0) { - return data.url; - } - let key = `${data.url}|${issues[0].type}|${issues[0]?.htmlWithIssues[0]?.target_selector || ''}`; - if (data.source) { - key += `|${data.source}`; - } - return key; - }; + log.info(`[A11yIndividual] ${aggregatedData.data.length} issues aggregated for opportunity ${opportunity.getId()}`); try { await syncSuggestions({ opportunity, newData: aggregatedData.data, context, - buildKey, + buildKey: buildSuggestionKey, // Map each URL's data to a suggestion format mapNewSuggestion: (urlData) => ({ opportunityId: opportunity.getId(), @@ -491,7 +600,9 @@ export async function sendMessageToMystiqueForRemediation( const suggestionData = suggestion.getData(); const issueTypes = suggestionData.issues ? suggestionData.issues.map((issue) => issue.type) : []; - log.debug(`[A11yIndividual] Suggestion ${index}: URL=${suggestionData.url}, Issues=[${issueTypes.join(', ')}]`); + // Log the database key (INDIVIDUAL level) for each suggestion + const databaseKey = buildSuggestionKey(suggestionData); + log.debug(`[A11yIndividual] Suggestion ${index}: URL=${suggestionData.url}, DatabaseKey=${databaseKey}, Issues=[${issueTypes.join(', ')}]`); }); // Process the suggestions directly to create Mystique messages @@ -510,10 +621,17 @@ export async function sendMessageToMystiqueForRemediation( return { success: false, error: 'Missing SQS context or queue configuration' }; } - log.debug(`[A11yIndividual] Sending ${mystiqueData.length} messages to Mystique queue: ${env.QUEUE_SPACECAT_TO_MYSTIQUE}`); + // Check if we have code fix eligible issues that require import worker + const hasCodeFixIssues = mystiqueData.some((data) => shouldUseCodeFixFlow(data.issuesList)); + if (hasCodeFixIssues && !env.IMPORT_WORKER_QUEUE_URL) { + log.error('[A11yIndividual][A11yProcessingError] Preconditions not met for code fix flow'); + return { success: false, error: 'Preconditions not met for code fix' }; + } + + log.info(`[A11yIndividual] Sending ${mystiqueData.length} messages to Mystique (via appropriate flow based on issue types)`); const messagePromises = mystiqueData.map(({ - url, issuesList, + url, issuesList, aggregationKey, }) => sendMystiqueMessage({ url, issuesList, @@ -521,9 +639,11 @@ export async function sendMessageToMystiqueForRemediation( siteId, auditId, deliveryType, + aggregationKey, sqs, env, log, + context, })); // Wait for all messages to be sent (successfully or with errors) const results = await Promise.allSettled(messagePromises); @@ -638,8 +758,10 @@ export async function createAccessibilityIndividualOpportunities(accessibilityDa site, log, } = context; + log.info(`[A11yIndividual] Creating accessibility opportunities for ${site.getBaseURL()}`); + // Step 1: Aggregate accessibility issues by URL - const aggregatedData = aggregateAccessibilityIssues(accessibilityData); + const aggregatedData = aggregateA11yIssuesByOppType(accessibilityData); log.debug(`[A11yIndividual] Aggregated data: ${JSON.stringify(aggregatedData, null, 2)}`); // Early return if no actionable issues found @@ -985,4 +1107,4 @@ export async function handleAccessibilityRemediationGuidance(message, context) { } // Export these for testing -export { createMystiqueMessage, sendMystiqueMessage }; +export { createDirectMystiqueMessage, sendMystiqueMessage }; diff --git a/src/common/codefix-handler.js b/src/common/codefix-handler.js new file mode 100644 index 000000000..2ef19a7bd --- /dev/null +++ b/src/common/codefix-handler.js @@ -0,0 +1,334 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable max-classes-per-file */ + +import { createHash } from 'crypto'; +import { isNonEmptyArray, buildAggregationKeyFromSuggestion } from '@adobe/spacecat-shared-utils'; +import { getObjectFromKey } from '../utils/s3-utils.js'; + +/** + * Custom error classes for code fix processing + */ +export class CodeFixValidationError extends Error { + constructor(message) { + super(message); + this.name = 'CodeFixValidationError'; + } +} + +export class CodeFixNotFoundError extends Error { + constructor(message) { + super(message); + this.name = 'CodeFixNotFoundError'; + } +} + +export class CodeFixConfigurationError extends Error { + constructor(message) { + super(message); + this.name = 'CodeFixConfigurationError'; + } +} + +/** + * Generates a hash for the given URL and source combination. + * @param {string} url - The URL to hash + * @param {string} source - The source to hash + * @returns {string} - The generated hash (first 16 characters of MD5) + */ +function generateUrlSourceHash(url, source) { + const combined = `${url}_${source}`; + return createHash('md5').update(combined).digest('hex').substring(0, 16); +} + +/** + * Reads code change report from S3 bucket + * @param {Object} s3Client - The S3 client instance + * @param {string} bucketName - The S3 bucket name + * @param {string} reportKey - The S3 key path to the report + * @param {Object} log - Logger instance + * @returns {Promise} - The report data or null if not found + */ +async function readCodeChangeReport(s3Client, bucketName, reportKey, log) { + try { + log.info(`Reading code change report from S3: ${reportKey}`); + + const reportData = await getObjectFromKey(s3Client, bucketName, reportKey, log); + + if (!reportData) { + log.warn(`No code change report found for key: ${reportKey}`); + return null; + } + + // If reportData is a plain string (not JSON), wrap it in a diff object + if (typeof reportData === 'string') { + log.info(`Report data is plain text, wrapping in diff object for key: ${reportKey}`); + return { diff: reportData }; + } + + log.info(`Successfully read code change report from S3: ${reportKey}`); + return reportData; + } catch (error) { + log.error(`Error reading code change report from S3: ${error.message}`, error); + return null; + } +} + +/** + * Updates suggestions with code change data + * @param {Array} suggestions - Array of suggestion objects + * @param {string} url - The page URL to match + * @param {string} source - The source to match (optional) + * @param {string} matchKey - The key to match (aggregation_key or ruleId from type) + * @param {Object} reportData - The code change report data + * @param {boolean} useAggregationKey - If true, build aggregation key from suggestion data; + * if false, use issue type (ruleId) + * @param {Object} log - Logger instance + * @returns {Promise} - Array of updated suggestions + */ +async function updateSuggestionsWithCodeChange( + suggestions, + url, + source, + matchKey, + reportData, + useAggregationKey, + log, +) { + const updatedSuggestions = []; + + try { + const promises = []; + for (const suggestion of suggestions) { + const suggestionData = suggestion.getData(); + + // Check if this suggestion matches the criteria + const suggestionUrl = suggestionData.url; + const suggestionSource = suggestionData.source; + + let suggestionMatchKey; + if (useAggregationKey) { + // Build aggregation key from suggestion data + suggestionMatchKey = buildAggregationKeyFromSuggestion(suggestionData); + } else { + // Use issue type (ruleId) for backwards compatibility + suggestionMatchKey = suggestionData.issues?.[0]?.type; + } + + if (suggestionUrl === url + && (!source || suggestionSource === source) + && suggestionMatchKey === matchKey + && !!reportData.diff) { + log.info(`Updating suggestion ${suggestion.getId()} with code change data`); + + // Update suggestion data with diff content and availability flag + const updatedData = { + ...suggestionData, + patchContent: reportData.diff, + isCodeChangeAvailable: true, + }; + + suggestion.setData(updatedData); + promises.push(suggestion.save()); + updatedSuggestions.push(suggestion); + } + } + + await Promise.all(promises); + log.info(`Updated ${updatedSuggestions.length} suggestions with code change data`); + return updatedSuggestions; + } catch (error) { + log.error(`Error updating suggestions with code change: ${error.message}`, error); + throw error; + } +} + +/** + * Processes code fix updates for an opportunity's suggestions + * + * @param {string} siteId - The site ID + * @param {string} opportunityId - The opportunity ID + * @param {Array} updates - Array of update objects with: + * - url (required) + * - source (optional) + * - aggregation_key (optional, new) OR type array (optional, old) + * - code_fix_path (optional, only with aggregation_key) + * - code_fix_bucket (optional, only with aggregation_key) + * @param {Object} context - Context object containing dataAccess, log, s3Client, env + * @returns {Promise} - Number of suggestions updated + * @throws {CodeFixValidationError} - If validation fails (bad request scenarios) + * @throws {CodeFixNotFoundError} - If opportunity not found + * @throws {CodeFixConfigurationError} - If configuration is missing + * @throws {Error} - For other processing errors + */ +export async function processCodeFixUpdates(siteId, opportunityId, updates, context) { + const { + log, dataAccess, s3Client, env, + } = context; + const { Opportunity } = dataAccess; + + // Validation + if (!opportunityId) { + log.error('[CodeFixProcessor] No opportunityId provided'); + throw new CodeFixValidationError('No opportunityId provided'); + } + + if (!isNonEmptyArray(updates)) { + log.error('[CodeFixProcessor] No updates provided or updates is empty'); + throw new CodeFixValidationError('No updates provided or updates is empty'); + } + + log.info(`[CodeFixProcessor] Processing code fix updates for siteId: ${siteId}, opportunityId: ${opportunityId}`); + + // Find the opportunity + const opportunity = await Opportunity.findById(opportunityId); + + if (!opportunity) { + log.error(`[CodeFixProcessor] Opportunity not found for ID: ${opportunityId}`); + throw new CodeFixNotFoundError(`Opportunity not found for ID: ${opportunityId}`); + } + + // Verify the opportunity belongs to the correct site + if (opportunity.getSiteId() !== siteId) { + const errorMsg = `[CodeFixProcessor] Site ID mismatch. Expected: ${siteId}, Found: ${opportunity.getSiteId()}`; + log.error(errorMsg); + throw new CodeFixValidationError(errorMsg); + } + + // Get all suggestions for the opportunity + const suggestions = await opportunity.getSuggestions(); + + if (!isNonEmptyArray(suggestions)) { + log.warn(`[CodeFixProcessor] No suggestions found for opportunity: ${opportunityId}`); + return 0; + } + + // Default bucket name from environment + const defaultBucketName = env.S3_MYSTIQUE_BUCKET_NAME; + + let totalUpdatedSuggestions = 0; + + // Process each update + await Promise.all(updates.map(async (update) => { + const { + url, + source, + aggregation_key: aggregationKey, + type: types, + code_fix_path: codeFixPath, + code_fix_bucket: codeFixBucket, + } = update; + + if (!url) { + log.warn('[CodeFixProcessor] Skipping update without URL'); + return; + } + + // NEW FORMAT: aggregation_key with optional custom S3 path + if (aggregationKey) { + log.info(`[CodeFixProcessor] Processing update (new format) for URL: ${url}, source: ${source || 'N/A'}, aggregation_key: ${aggregationKey}`); + + // Determine bucket and path to use + let bucketName; + let reportKey; + + if (codeFixPath && codeFixBucket) { + // Use provided path and bucket with priority + bucketName = codeFixBucket; + reportKey = codeFixPath; + log.info(`[CodeFixProcessor] Using provided S3 path: s3://${bucketName}/${reportKey}`); + } else { + // Fall back to default path construction + if (!defaultBucketName) { + log.error('[CodeFixProcessor] S3_MYSTIQUE_BUCKET_NAME environment variable not set and no code_fix_bucket provided'); + throw new CodeFixConfigurationError('S3 bucket name not configured'); + } + bucketName = defaultBucketName; + const urlSourceHash = generateUrlSourceHash(url, source || ''); + reportKey = `fixes/${siteId}/${urlSourceHash}/${aggregationKey}/report.json`; + log.info(`[CodeFixProcessor] Using default S3 path: s3://${bucketName}/${reportKey}`); + } + + const reportData = await readCodeChangeReport( + s3Client, + bucketName, + reportKey, + log, + ); + + if (!reportData) { + log.warn(`[CodeFixProcessor] No code change report found for URL: ${url}, aggregation_key: ${aggregationKey}`); + return; + } + + // Update matching suggestions with the code change data + const updatedSuggestions = await updateSuggestionsWithCodeChange( + suggestions, + url, + source, + aggregationKey, + reportData, + true, // useAggregationKey = true for new format + log, + ); + totalUpdatedSuggestions += updatedSuggestions.length; + return; + } + + // OLD FORMAT: type array (backwards compatible) + if (!isNonEmptyArray(types)) { + log.warn(`[CodeFixProcessor] Skipping update for URL ${url} without aggregation_key or types`); + return; + } + + if (!defaultBucketName) { + log.error('[CodeFixProcessor] S3_MYSTIQUE_BUCKET_NAME environment variable not set'); + throw new CodeFixConfigurationError('S3 bucket name not configured'); + } + + log.info(`[CodeFixProcessor] Processing update (old format) for URL: ${url}, source: ${source || 'N/A'}, types: ${types.join(', ')}`); + + // For each type in the update, try to read the code change report + await Promise.all(types.map(async (ruleId) => { + const urlSourceHash = generateUrlSourceHash(url, source || ''); + const reportKey = `fixes/${siteId}/${urlSourceHash}/${ruleId}/report.json`; + + const reportData = await readCodeChangeReport( + s3Client, + defaultBucketName, + reportKey, + log, + ); + + if (!reportData) { + log.warn(`[CodeFixProcessor] No code change report found for URL: ${url}, source: ${source}, type: ${ruleId}`); + return; + } + + // Update matching suggestions with the code change data + const updatedSuggestions = await updateSuggestionsWithCodeChange( + suggestions, + url, + source, + ruleId, + reportData, + false, // useAggregationKey = false for old format (use ruleId) + log, + ); + totalUpdatedSuggestions += updatedSuggestions.length; + })); + })); + + log.info(`[CodeFixProcessor] Successfully processed all updates. Total suggestions updated: ${totalUpdatedSuggestions}`); + return totalUpdatedSuggestions; +} diff --git a/src/common/codefix-response-handler.js b/src/common/codefix-response-handler.js new file mode 100644 index 000000000..fe77e3625 --- /dev/null +++ b/src/common/codefix-response-handler.js @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + ok, badRequest, notFound, internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { + processCodeFixUpdates, + CodeFixValidationError, + CodeFixNotFoundError, + CodeFixConfigurationError, +} from './codefix-handler.js'; + +/** + * Common handler for processing code fix responses from Mystique/Importer + * + * This handler receives code fix results and updates suggestions with the generated fixes. + * It's used by all code fix workflows (accessibility, forms, etc.) + * + * Supports two message formats for backwards compatibility: + * + * NEW FORMAT (with aggregation_key): + * { + * "siteId": "", + * "type": "codefix:*", // e.g., "codefix:accessibility", "codefix:forms" + * "data": { + * "opportunityId": "", + * "updates": [ + * { + * "url": "", + * "source": "", // optional - source identifier + * "aggregation_key": "", // required - used to match suggestions + * "code_fix_path": "", // optional - S3 path to report.json + * "code_fix_bucket": "" // optional - S3 bucket name + * } + * ] + * } + * } + * + * OLD FORMAT (with type array, backwards compatible): + * { + * "siteId": "", + * "type": "codefix:*", + * "data": { + * "opportunityId": "", + * "updates": [ + * { + * "url": "", + * "source": "", // optional + * "type": ["rule-id-1", "rule-id-2"] // Array of fix types/rule IDs + * } + * ] + * } + * } + * + * If aggregation_key is provided, it takes precedence over type array. + * If code_fix_path and code_fix_bucket are provided (only with aggregation_key), + * they take priority over the default path construction: + * fixes/${siteId}/${urlSourceHash}/${aggregationKey}/report.json + * + * @param {Object} message - The SQS message + * @param {Object} context - The context object containing dataAccess, log, s3Client, etc. + * @returns {Promise} - HTTP response + */ +export default async function codeFixResponseHandler(message, context) { + const { log } = context; + const { siteId, type, data } = message; + + if (!data) { + log.error(`[CodeFixResponseHandler] No data provided in message for type: ${type}`); + return badRequest('No data provided in message'); + } + + const { opportunityId, updates } = data; + + log.info(`[CodeFixResponseHandler] Processing code fix response for type: ${type}, siteId: ${siteId}, opportunityId: ${opportunityId}`); + + try { + const totalUpdated = await processCodeFixUpdates(siteId, opportunityId, updates, context); + log.info(`[CodeFixResponseHandler] Successfully updated ${totalUpdated} suggestions for ${type}`); + return ok(); + } catch (error) { + if (error instanceof CodeFixValidationError) { + log.error(`[CodeFixResponseHandler] Validation error for ${type}: ${error.message}`); + return badRequest(error.message); + } + if (error instanceof CodeFixNotFoundError) { + log.error(`[CodeFixResponseHandler] Not found for ${type}: ${error.message}`); + return notFound(error.message); + } + if (error instanceof CodeFixConfigurationError) { + log.error(`[CodeFixResponseHandler] Configuration error for ${type}: ${error.message}`); + return internalServerError(error.message); + } + log.error(`[CodeFixResponseHandler] Unexpected error for ${type}: ${error.message}`, error); + return internalServerError(error.message); + } +} diff --git a/src/common/index.js b/src/common/index.js index 500cc7a0f..7b7bcbe67 100755 --- a/src/common/index.js +++ b/src/common/index.js @@ -18,3 +18,6 @@ export { AsyncJobRunner } from './async-job-runner.js'; // Export utility functions export * from './audit-utils.js'; + +// Export code fix handler utilities +export * from './codefix-handler.js'; diff --git a/src/forms-opportunities/oppty-handlers/accessibility-handler.js b/src/forms-opportunities/oppty-handlers/accessibility-handler.js index df1a57901..5e8fddc6e 100644 --- a/src/forms-opportunities/oppty-handlers/accessibility-handler.js +++ b/src/forms-opportunities/oppty-handlers/accessibility-handler.js @@ -20,7 +20,7 @@ import { } from '../utils.js'; import { updateStatusToIgnored } from '../../accessibility/utils/scrape-utils.js'; import { - aggregateAccessibilityIssues, + aggregateA11yIssuesByOppType, createIndividualOpportunitySuggestions, } from '../../accessibility/utils/generate-individual-opportunities.js'; import { aggregateAccessibilityData, sendRunImportMessage, sendCodeFixMessagesToMystique } from '../../accessibility/utils/data-processing.js'; @@ -310,7 +310,7 @@ async function createFormAccessibilityIndividualSuggestions(aggregatedData, oppo } }); - const aggregatedIssues = aggregateAccessibilityIssues( + const aggregatedIssues = aggregateA11yIssuesByOppType( transformedAccessibilityData, formOpportunitiesMap, ); diff --git a/src/forms-opportunities/utils.js b/src/forms-opportunities/utils.js index 8ddffe4e6..2de71209e 100644 --- a/src/forms-opportunities/utils.js +++ b/src/forms-opportunities/utils.js @@ -21,6 +21,7 @@ import { import { FORM_OPPORTUNITY_TYPES, successCriteriaLinks } from './constants.js'; import { calculateCPCValue } from '../support/utils.js'; import { getPresignedUrl as getPresignedUrlUtil } from '../utils/getPresignedUrl.js'; +import { isAuditEnabledForSite } from '../common/audit-utils.js'; function getS3PathPrefix(url, site) { const urlObj = new URL(url); @@ -664,3 +665,96 @@ export function applyOpportunityFilters( return opportunities; } + +/** + * Groups suggestions by URL, source, and issue type, then sends messages + * to the importer worker for code-fix generation + * + * @param {Object} opportunity - The opportunity object containing suggestions + * @param {string} auditId - The audit ID + * @param {Object} context - The context object containing log, sqs, env, and site + * @param {string} forwardMessageType - The message type to use in the forward object + * (default: 'codefix:accessibility') + * @returns {Promise} + */ +export async function sendCodeFixMessagesToImporter(opportunity, auditId, context, forwardMessageType = 'codefix:accessibility') { + const { + log, sqs, env, site, + } = context; + + const siteId = opportunity.getSiteId(); + const baseUrl = site.getBaseURL(); + try { + const isAutoFixEnabled = await isAuditEnabledForSite(`${opportunity.getType()}-auto-fix`, site, context); + if (!isAutoFixEnabled) { + log.info(`[Form Opportunity] [Site Id: ${siteId}] ${opportunity.getType()}-auto-fix is disabled for site, skipping code-fix generation`); + return; + } + + // Get all suggestions from the opportunity + const suggestions = await opportunity.getSuggestions(); + if (!suggestions || suggestions.length === 0) { + log.info(`[Form Opportunity] [Site Id: ${siteId}] No suggestions found for code-fix generation`); + return; + } + + // Group suggestions by URL, source, and issueType + const groupedSuggestions = new Map(); + + suggestions.forEach((suggestion) => { + const suggestionData = suggestion.getData(); + const { url, source = 'default', issues } = suggestionData; + + // By design, data.issues will always have length 1 + if (issues && issues.length > 0) { + const issueType = issues[0].type; + const groupKey = `${url}|${source}|${issueType}`; + if (!groupedSuggestions.has(groupKey)) { + groupedSuggestions.set(groupKey, { + url, + source, + issueType, + suggestionIds: [], + }); + } + + // Add the suggestion ID to the group + groupedSuggestions.get(groupKey).suggestionIds.push(suggestion.getId()); + } + }); + + log.info(`[Form Opportunity] [Site Id: ${siteId}] Grouped suggestions into ${groupedSuggestions.size} groups for code-fix generation`); + + const messagePromises = Array.from(groupedSuggestions.values()).map(async (group) => { + const message = { + type: 'code', + siteId, + forward: { + queue: env.QUEUE_SPACECAT_TO_MYSTIQUE, + payload: { + type: forwardMessageType, + siteId, + auditId, + url: baseUrl, + data: { + opportunityId: opportunity.getId(), + suggestionIds: group.suggestionIds, + }, + }, + }, + }; + + try { + await sqs.sendMessage(env.IMPORT_WORKER_QUEUE_URL, message); + log.info(`[Form Opportunity] [Site Id: ${siteId}] Sent code-fix message (forward type: ${forwardMessageType}) to importer for URL: ${group.url}, source: ${group.source}, issueType: ${group.issueType}, suggestions: ${group.suggestionIds.length}`); + } catch (error) { + log.error(`[Form Opportunity] [Site Id: ${siteId}] Failed to send code-fix message for URL: ${group.url}, error: ${error.message}`); + } + }); + + await Promise.all(messagePromises); + log.info(`[Form Opportunity] [Site Id: ${siteId}] Completed sending ${messagePromises.length} code-fix messages to importer`); + } catch (error) { + log.error(`[Form Opportunity] [Site Id: ${siteId}] Error in sendCodeFixMessagesToImporter: ${error.message}`); + } +} diff --git a/src/index.js b/src/index.js index a1e648a7a..a1c50b86f 100644 --- a/src/index.js +++ b/src/index.js @@ -64,6 +64,7 @@ import formAccessibilityGuidance from './forms-opportunities/guidance-handlers/g import detectFormDetails from './forms-opportunities/form-details-handler/detect-form-details.js'; import mystiqueDetectedFormAccessibilityOpportunity from './forms-opportunities/oppty-handlers/accessibility-handler.js'; import accessibilityRemediationGuidance from './accessibility/guidance-handlers/guidance-accessibility-remediation.js'; +import accessibilityCodeFix from './common/codefix-response-handler.js'; import cdnLogsAnalysis from './cdn-analysis/handler.js'; import cdnLogsReport from './cdn-logs-report/handler.js'; import analyticsReport from './analytics-report/handler.js'; @@ -139,6 +140,7 @@ const HANDLERS = { 'guidance:forms-a11y': formAccessibilityGuidance, 'detect:forms-a11y': mystiqueDetectedFormAccessibilityOpportunity, 'guidance:accessibility-remediation': accessibilityRemediationGuidance, + 'codefix:accessibility': accessibilityCodeFix, 'guidance:paid-cookie-consent': paidConsentGuidance, 'guidance:traffic-analysis': paidTrafficAnalysisGuidance, 'detect:page-types': pageTypeGuidance, diff --git a/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js b/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js index bb3665828..a7a511a74 100644 --- a/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js +++ b/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js @@ -54,6 +54,10 @@ describe('AccessibilityCodeFixHandler', () => { getObjectFromKeyStub = sandbox.stub(); + const mockS3Client = { + send: sandbox.stub().resolves(), + }; + context = new MockContextBuilder() .withSandbox(sandbox) .withOverrides({ @@ -64,6 +68,7 @@ describe('AccessibilityCodeFixHandler', () => { error: sandbox.spy(), }, dataAccess: mockDataAccess, + s3Client: mockS3Client, env: { S3_MYSTIQUE_BUCKET_NAME: 'test-mystique-bucket', }, @@ -84,7 +89,7 @@ describe('AccessibilityCodeFixHandler', () => { { url: 'https://example.com/contact', source: 'form', - type: ['color-contrast'], + aggregation_key: 'https://example.com/contact|button-name|form', // PER_PAGE_PER_COMPONENT: url|type|source }, ], }, @@ -92,33 +97,47 @@ describe('AccessibilityCodeFixHandler', () => { describe('Main Handler Function', () => { it('should successfully process updates with matching suggestions', async () => { - const mockReportData = JSON.stringify({ + const mockReportData = { url: 'https://example.com/contact', source: 'form', - type: 'color-contrast', + aggregation_key: 'https://example.com/contact|button-name|form', diff: 'mock diff content', - }); + }; const suggestionData = { url: 'https://example.com/contact', source: 'form', - issues: [{ type: 'color-contrast' }], + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], }; mockSuggestion.getData.returns(suggestionData); getObjectFromKeyStub.resolves(mockReportData); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); expect(result.status).to.equal(200); expect(mockSuggestion.setData).to.have.been.calledWith({ - ...suggestionData, + url: 'https://example.com/contact', + source: 'form', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], patchContent: 'mock diff content', isCodeChangeAvailable: true, }); @@ -126,10 +145,12 @@ describe('AccessibilityCodeFixHandler', () => { }); it('should return badRequest when no data provided', async () => { - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const message = { siteId: 'site-123', type: 'codefix:accessibility' }; @@ -137,15 +158,17 @@ describe('AccessibilityCodeFixHandler', () => { expect(result.status).to.equal(400); expect(context.log.error).to.have.been.calledWith( - 'AccessibilityCodeFixHandler: No data provided in message', + sinon.match(/No data provided in message/), ); }); it('should return badRequest when no opportunityId provided', async () => { - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const message = { @@ -156,15 +179,17 @@ describe('AccessibilityCodeFixHandler', () => { expect(result.status).to.equal(400); expect(context.log.error).to.have.been.calledWith( - '[AccessibilityCodeFixHandler] No opportunityId provided', + sinon.match(/Validation error.*No opportunityId provided/), ); }); it('should return badRequest when no updates provided', async () => { - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const message = { @@ -179,27 +204,31 @@ describe('AccessibilityCodeFixHandler', () => { it('should return notFound when opportunity not found', async () => { mockDataAccess.Opportunity.findById.resolves(null); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); expect(result.status).to.equal(404); expect(context.log.error).to.have.been.calledWith( - '[AccessibilityCodeFixHandler] Opportunity not found for ID: opportunity-123', + sinon.match(/Not found.*Opportunity not found for ID: opportunity-123/), ); }); it('should return badRequest when site ID mismatch', async () => { mockOpportunity.getSiteId.returns('different-site-id'); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -210,10 +239,12 @@ describe('AccessibilityCodeFixHandler', () => { it('should return ok when no suggestions found', async () => { mockOpportunity.getSuggestions.resolves([]); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -224,10 +255,12 @@ describe('AccessibilityCodeFixHandler', () => { it('should return internalServerError when S3 bucket not configured', async () => { context.env.S3_MYSTIQUE_BUCKET_NAME = undefined; - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -238,10 +271,12 @@ describe('AccessibilityCodeFixHandler', () => { it('should handle missing S3 reports gracefully', async () => { getObjectFromKeyStub.resolves(null); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -255,10 +290,12 @@ describe('AccessibilityCodeFixHandler', () => { it('should handle S3 errors gracefully', async () => { getObjectFromKeyStub.rejects(new Error('S3 access denied')); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -277,16 +314,18 @@ describe('AccessibilityCodeFixHandler', () => { const suggestionData = { url: 'https://different.com/test', // Different URL source: 'form', - issues: [{ type: 'color-contrast' }], + aggregation_key: 'color-contrast', }; mockSuggestion.getData.returns(suggestionData); getObjectFromKeyStub.resolves(mockReportData); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -297,25 +336,29 @@ describe('AccessibilityCodeFixHandler', () => { }); it('should not update suggestions without diff content', async () => { - const mockReportData = JSON.stringify({ + // Return a parsed object (not stringified) without diff property + // This simulates JSON content from S3 that has been parsed + const mockReportData = { // No diff property url: 'https://example.com/contact', source: 'form', - }); + }; const suggestionData = { url: 'https://example.com/contact', source: 'form', - issues: [{ type: 'color-contrast' }], + aggregation_key: 'color-contrast', }; mockSuggestion.getData.returns(suggestionData); getObjectFromKeyStub.resolves(mockReportData); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); @@ -325,65 +368,74 @@ describe('AccessibilityCodeFixHandler', () => { }); it('should handle suggestion save errors', async () => { - const mockReportData = JSON.stringify({ + const mockReportData = { diff: 'mock diff content', - }); + }; const suggestionData = { url: 'https://example.com/contact', source: 'form', - issues: [{ type: 'color-contrast' }], + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], }; mockSuggestion.getData.returns(suggestionData); mockSuggestion.save.rejects(new Error('Save failed')); getObjectFromKeyStub.resolves(mockReportData); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); expect(result.status).to.equal(500); expect(context.log.error).to.have.been.calledWith( - sinon.match(/Error updating suggestions with code change data/), + sinon.match(/Error updating suggestions with code change/), ); }); it('should handle processing errors gracefully', async () => { mockDataAccess.Opportunity.findById.rejects(new Error('Database error')); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const result = await handler.default(validMessage, context); expect(result.status).to.equal(500); expect(context.log.error).to.have.been.calledWith( - sinon.match(/Error processing message: Database error/), + sinon.match(/Unexpected error.*Database error/), ); }); - it('should process multiple updates with multiple types', async () => { + it('should process multiple updates with different aggregation keys', async () => { const mockReportData1 = JSON.stringify({ diff: 'diff for color-contrast' }); const mockReportData2 = JSON.stringify({ diff: 'diff for select-name' }); const suggestionData1 = { url: 'https://example.com/page1', source: 'form1', - issues: [{ type: 'color-contrast' }], + aggregation_key: 'color-contrast', }; const suggestionData2 = { url: 'https://example.com/page1', source: 'form1', - issues: [{ type: 'select-name' }], + aggregation_key: 'select-name', }; const mockSuggestion2 = { @@ -400,10 +452,12 @@ describe('AccessibilityCodeFixHandler', () => { getObjectFromKeyStub.onFirstCall().resolves(mockReportData1); getObjectFromKeyStub.onSecondCall().resolves(mockReportData2); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const multiUpdateMessage = { @@ -414,7 +468,12 @@ describe('AccessibilityCodeFixHandler', () => { { url: 'https://example.com/page1', source: 'form1', - type: ['color-contrast', 'select-name'], + aggregation_key: 'color-contrast', + }, + { + url: 'https://example.com/page1', + source: 'form1', + aggregation_key: 'select-name', }, ], }, @@ -426,11 +485,13 @@ describe('AccessibilityCodeFixHandler', () => { expect(getObjectFromKeyStub).to.have.been.calledTwice; }); - it('should skip updates without URL or types', async () => { - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, - }, + it('should skip updates without URL or aggregation_key', async () => { + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); const messageWithoutUrl = { @@ -440,12 +501,12 @@ describe('AccessibilityCodeFixHandler', () => { updates: [ { source: 'form', - type: ['color-contrast'], + aggregation_key: 'color-contrast', }, { url: 'https://example.com/test', source: 'form', - // No type + // No aggregation_key or type }, ], }, @@ -454,51 +515,455 @@ describe('AccessibilityCodeFixHandler', () => { const result = await handler.default(messageWithoutUrl, context); expect(result.status).to.equal(200); - expect(context.log.warn).to.have.been.calledWith( - '[AccessibilityCodeFixHandler] Skipping update without URL', - ); - expect(context.log.warn).to.have.been.calledWith( - sinon.match(/Skipping update for URL.*without types/), + // These warnings are now in the common handler + expect(context.log.warn).to.have.been.called; + }); + + it('should handle error when S3_MYSTIQUE_BUCKET_NAME not set for old format', async () => { + context.env.S3_MYSTIQUE_BUCKET_NAME = undefined; + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const messageOldFormat = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + source: 'form', + type: ['color-contrast'], + }, + ], + }, + }; + + const result = await handler.default(messageOldFormat, context); + + expect(result.status).to.equal(500); + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Configuration error.*S3 bucket name not configured/), ); }); it('should work without source parameter', async () => { - const mockReportData = JSON.stringify({ + const mockReportData = { diff: 'mock diff content', + }; + + const suggestionData = { + url: 'https://example.com/contact', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); + const messageWithoutSource = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + aggregation_key: 'https://example.com/contact|button-name', // No source + }, + ], + }, + }; + + const result = await handler.default(messageWithoutSource, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).to.have.been.called; + }); + + it('should use provided code_fix_path and code_fix_bucket when available', async () => { + const mockReportData = { + diff: 'mock diff content from custom path', + }; + const suggestionData = { url: 'https://example.com/contact', - source: 'any-source', - issues: [{ type: 'color-contrast' }], + source: 'form', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], }; mockSuggestion.getData.returns(suggestionData); getObjectFromKeyStub.resolves(mockReportData); - const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { - '../../../../src/utils/s3-utils.js': { - getObjectFromKey: getObjectFromKeyStub, + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const messageWithCustomPath = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + source: 'form', + aggregation_key: 'https://example.com/contact|button-name|form', + code_fix_path: 'custom/path/to/report.json', + code_fix_bucket: 'custom-bucket', + }, + ], }, + }; + + const result = await handler.default(messageWithCustomPath, context); + + expect(result.status).to.equal(200); + expect(getObjectFromKeyStub).to.have.been.calledWith( + sinon.match.any, + 'custom-bucket', + 'custom/path/to/report.json', + sinon.match.any, + ); + expect(mockSuggestion.setData).to.have.been.called; + }); + + it('should support old format with type array (backwards compatible)', async () => { + const mockReportData = { + diff: 'mock diff content', + }; + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), }); - const messageWithoutSource = { + const messageOldFormat = { siteId: 'site-123', data: { opportunityId: 'opportunity-123', updates: [ { url: 'https://example.com/contact', + source: 'form', type: ['color-contrast'], }, ], }, }; - const result = await handler.default(messageWithoutSource, context); + const result = await handler.default(messageOldFormat, context); expect(result.status).to.equal(200); expect(mockSuggestion.setData).to.have.been.called; }); + + it('should process multiple types in old format', async () => { + const mockReportData1 = { diff: 'diff for color-contrast' }; + const mockReportData2 = { diff: 'diff for select-name' }; + + const suggestionData1 = { + url: 'https://example.com/page1', + source: 'form1', + issues: [{ type: 'color-contrast' }], + }; + + const suggestionData2 = { + url: 'https://example.com/page1', + source: 'form1', + issues: [{ type: 'select-name' }], + }; + + const mockSuggestion2 = { + getId: sandbox.stub().returns('suggestion-456'), + getData: sandbox.stub().returns(suggestionData2), + setData: sandbox.stub(), + setUpdatedBy: sandbox.stub(), + save: sandbox.stub().resolves(), + }; + + mockSuggestion.getData.returns(suggestionData1); + mockOpportunity.getSuggestions.resolves([mockSuggestion, mockSuggestion2]); + + getObjectFromKeyStub.onFirstCall().resolves(mockReportData1); + getObjectFromKeyStub.onSecondCall().resolves(mockReportData2); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const messageOldFormat = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/page1', + source: 'form1', + type: ['color-contrast', 'select-name'], + }, + ], + }, + }; + + const result = await handler.default(messageOldFormat, context); + + expect(result.status).to.equal(200); + expect(getObjectFromKeyStub).to.have.been.calledTwice; + expect(mockSuggestion.setData).to.have.been.called; + expect(mockSuggestion2.setData).to.have.been.called; + }); + + it('should prefer aggregation_key over type array when both present', async () => { + const mockReportData = { + diff: 'mock diff content', + }; + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const messageBothFormats = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + source: 'form', + aggregation_key: 'https://example.com/contact|button-name|form', + type: ['should-be-ignored'], + }, + ], + }, + }; + + const result = await handler.default(messageBothFormats, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).to.have.been.called; + // Should only be called once for aggregation_key, not for type array + expect(getObjectFromKeyStub).to.have.been.calledOnce; + }); + + it('should handle plain text diff content from S3 (non-JSON)', async () => { + // Simulate plain text content from S3 (string instead of object) + const plainTextDiff = 'diff --git a/file.js b/file.js\nindex 123..456\n--- a/file.js\n+++ b/file.js\n@@ -1,3 +1,3 @@\n-old line\n+new line'; + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], + }; + + mockSuggestion.getData.returns(suggestionData); + // Return plain string instead of JSON object - this will be wrapped as {diff: string} + getObjectFromKeyStub.resolves(plainTextDiff); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const message = { + siteId: 'site-123', + type: 'codefix:accessibility', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + source: 'form', + aggregation_key: 'https://example.com/contact|button-name|form', + }, + ], + }, + }; + + const result = await handler.default(message, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).to.have.been.calledWith({ + url: 'https://example.com/contact', + source: 'form', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + target_selector: 'button.submit', + }], + }], + patchContent: plainTextDiff, + isCodeChangeAvailable: true, + }); + expect(mockSuggestion.save).to.have.been.called; + }); + + it('should update multiple suggestions with same aggregation_key', async () => { + const mockReportData = { + diff: 'mock diff content for aria-prohibited-attr', + }; + + // aria-prohibited-attr has PER_TYPE granularity, so aggregation key is just the type + const suggestionData1 = { + url: 'https://example.com/page1', + source: 'form1', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ + target_selector: 'div[aria-hidden]', + }], + }], + }; + + const suggestionData2 = { + url: 'https://example.com/page2', + source: 'form2', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ + target_selector: 'span.label', + }], + }], + }; + + const mockSuggestion2 = { + getId: sandbox.stub().returns('suggestion-456'), + getData: sandbox.stub().returns(suggestionData2), + setData: sandbox.stub(), + setUpdatedBy: sandbox.stub(), + save: sandbox.stub().resolves(), + }; + + mockSuggestion.getData.returns(suggestionData1); + mockOpportunity.getSuggestions.resolves([mockSuggestion, mockSuggestion2]); + + getObjectFromKeyStub.onFirstCall().resolves(mockReportData); + getObjectFromKeyStub.onSecondCall().resolves(mockReportData); + + const handler = await esmock('../../../../src/common/codefix-response-handler.js', { + '../../../../src/common/codefix-handler.js': await esmock('../../../../src/common/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }), + }); + + const message = { + siteId: 'site-123', + type: 'codefix:accessibility', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/page1', + source: 'form1', + aggregation_key: 'aria-prohibited-attr', + }, + { + url: 'https://example.com/page2', + source: 'form2', + aggregation_key: 'aria-prohibited-attr', + }, + ], + }, + }; + + const result = await handler.default(message, context); + + expect(result.status).to.equal(200); + // Both suggestions should be updated + expect(mockSuggestion.setData).to.have.been.calledWith({ + url: 'https://example.com/page1', + source: 'form1', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ + target_selector: 'div[aria-hidden]', + }], + }], + patchContent: 'mock diff content for aria-prohibited-attr', + isCodeChangeAvailable: true, + }); + expect(mockSuggestion2.setData).to.have.been.calledWith({ + url: 'https://example.com/page2', + source: 'form2', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ + target_selector: 'span.label', + }], + }], + patchContent: 'mock diff content for aria-prohibited-attr', + isCodeChangeAvailable: true, + }); + expect(mockSuggestion.save).to.have.been.called; + expect(mockSuggestion2.save).to.have.been.called; + }); }); }); \ No newline at end of file diff --git a/test/audits/accessibility/generate-individual-opportunities.test.js b/test/audits/accessibility/generate-individual-opportunities.test.js index f519829a4..213e859ce 100644 --- a/test/audits/accessibility/generate-individual-opportunities.test.js +++ b/test/audits/accessibility/generate-individual-opportunities.test.js @@ -20,7 +20,7 @@ import esmock from 'esmock'; import { formatWcagRule, formatIssue, - aggregateAccessibilityIssues, + aggregateA11yIssuesByOppType, createIndividualOpportunity, calculateAccessibilityMetrics, } from '../../../src/accessibility/utils/generate-individual-opportunities.js'; @@ -804,12 +804,12 @@ describe('aggregateAccessibilityIssues', () => { }); it('should return empty data array for null input', () => { - const result = aggregateAccessibilityIssues(null); + const result = aggregateA11yIssuesByOppType(null); expect(result).to.deep.equal({ data: [] }); }); it('should return empty data array for undefined input', () => { - const result = aggregateAccessibilityIssues(undefined); + const result = aggregateA11yIssuesByOppType(undefined); expect(result).to.deep.equal({ data: [] }); }); @@ -822,7 +822,7 @@ describe('aggregateAccessibilityIssues', () => { }, }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.be.empty; }); @@ -845,7 +845,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive']).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive'][0].url).to.equal('https://example.com'); @@ -876,7 +876,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive']).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive'][0].url).to.equal('https://example.com'); @@ -918,7 +918,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); const opportunity = result.data[0]; expect(opportunity['a11y-assistive']).to.have.lengthOf(2); // Now creates separate URL objects @@ -968,7 +968,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); const opportunity = result.data[0]; expect(opportunity['a11y-assistive']).to.have.lengthOf(2); @@ -1002,7 +1002,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive']).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive'][0].url).to.equal('https://example.com/page2'); @@ -1013,7 +1013,7 @@ describe('aggregateAccessibilityIssues', () => { 'https://example.com': {}, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.be.empty; }); @@ -1027,7 +1027,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.be.empty; }); @@ -1049,7 +1049,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.be.empty; }); @@ -1073,7 +1073,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); const opportunity = result.data[0]; @@ -1114,10 +1114,10 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); const opportunity = result.data[0]; - expect(opportunity['a11y-assistive']).to.have.lengthOf(3); // Creates 3 separate URL objects + expect(opportunity['a11y-assistive']).to.have.lengthOf(3); // Creates 3 separate URL objects (one per HTML element) // Verify each URL object has one issue with one HTML element opportunity['a11y-assistive'].forEach((urlObject) => { @@ -1127,21 +1127,17 @@ describe('aggregateAccessibilityIssues', () => { expect(urlObject.issues[0].htmlWithIssues).to.have.lengthOf(1); }); - // Verify specific HTML content - expect(opportunity['a11y-assistive'][0].issues[0].htmlWithIssues[0].update_from) - .to.equal('
Content 1
'); - expect(opportunity['a11y-assistive'][1].issues[0].htmlWithIssues[0].update_from) - .to.equal('Content 2'); - expect(opportunity['a11y-assistive'][2].issues[0].htmlWithIssues[0].update_from) - .to.equal('

Content 3

'); + // Verify all HTML elements are present across the URL objects (order may vary) + const allUpdateFromValues = opportunity['a11y-assistive'].map((obj) => obj.issues[0].htmlWithIssues[0].update_from); + const allTargetSelectors = opportunity['a11y-assistive'].map((obj) => obj.issues[0].htmlWithIssues[0].target_selector); + + expect(allUpdateFromValues).to.include('
Content 1
'); + expect(allUpdateFromValues).to.include('Content 2'); + expect(allUpdateFromValues).to.include('

Content 3

'); - // Verify specific target - expect(opportunity['a11y-assistive'][0].issues[0].htmlWithIssues[0].target_selector) - .to.equal('div[aria-fake]'); - expect(opportunity['a11y-assistive'][1].issues[0].htmlWithIssues[0].target_selector) - .to.equal('span[aria-invalid-attr]'); - expect(opportunity['a11y-assistive'][2].issues[0].htmlWithIssues[0].target_selector) - .to.equal('p[aria-made-up]'); + expect(allTargetSelectors).to.include('div[aria-fake]'); + expect(allTargetSelectors).to.include('span[aria-invalid-attr]'); + expect(allTargetSelectors).to.include('p[aria-made-up]'); }); it('should return original url if URL parsing fails', () => { @@ -1163,7 +1159,7 @@ describe('aggregateAccessibilityIssues', () => { }, }; - const result = aggregateAccessibilityIssues(input); + const result = aggregateA11yIssuesByOppType(input); expect(result.data).to.have.lengthOf(1); expect(result.data[0]['a11y-assistive'][0].url).to.equal('https://example.com:port'); }); @@ -1330,6 +1326,7 @@ describe('createIndividualOpportunitySuggestions', () => { sendMessage: sandbox.stub().resolves(), }, env: { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', }, }; @@ -1628,6 +1625,8 @@ describe('createIndividualOpportunitySuggestions', () => { // Test the buildKey function with missing target_selector const result = buildKey(aggregatedData.data[0]); + // buildKey always uses INDIVIDUAL granularity for database uniqueness: url|type|selector + // Empty selector results in trailing pipe for backwards compatibility expect(result).to.equal('https://example.com/page3|image-alt|'); }); @@ -1664,6 +1663,8 @@ describe('createIndividualOpportunitySuggestions', () => { // Test the buildKey function with null target_selector const result = buildKey(aggregatedData.data[0]); + // buildKey always uses INDIVIDUAL granularity: url|type|selector + // Null selector results in trailing pipe expect(result).to.equal('https://example.com/page4|button-name|'); }); @@ -1695,6 +1696,8 @@ describe('createIndividualOpportunitySuggestions', () => { // Test the buildKey function with empty htmlWithIssues const result = buildKey(aggregatedData.data[0]); + // buildKey always uses INDIVIDUAL granularity: url|type|selector + // Empty htmlWithIssues results in empty selector and trailing pipe expect(result).to.equal('https://example.com/page5|label|'); }); @@ -1915,6 +1918,7 @@ describe('createAccessibilityIndividualOpportunities', () => { sendMessage: sandbox.stub().resolves(), }, env: { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', }, }; @@ -2676,14 +2680,14 @@ describe('createAccessibilityIndividualOpportunities', () => { }); }); -describe('createMystiqueMessage', () => { +describe('createDirectMystiqueMessage', () => { it('should create a message object with all required fields', () => { const fakeOpportunity = { getId: () => 'oppty-123' }; const issuesList = [{ type: 'color-contrast', description: 'desc' }]; const siteId = 'site-789'; const auditId = 'audit-101'; const deliveryType = 'aem_edge'; - const result = generateIndividualOpportunitiesModule.createMystiqueMessage({ + const result = generateIndividualOpportunitiesModule.createDirectMystiqueMessage({ url: 'https://example.com', issuesList, opportunity: fakeOpportunity, @@ -2708,7 +2712,7 @@ describe('createMystiqueMessage', () => { it('should default siteId and auditId to empty string if not provided', () => { const fakeOpportunity = { getId: () => 'oppty-123' }; const issuesList = []; - const result = generateIndividualOpportunitiesModule.createMystiqueMessage({ + const result = generateIndividualOpportunitiesModule.createDirectMystiqueMessage({ url: 'https://example.com', issuesList, opportunity: fakeOpportunity, @@ -2727,13 +2731,39 @@ describe('sendMystiqueMessage', () => { let fakeEnv; let fakeLog; let fakeOpportunity; + let fakeContext; + let mockIsAuditEnabledForSite; beforeEach(() => { sandbox = sinon.createSandbox(); fakeSqs = { sendMessage: sandbox.stub().resolves() }; - fakeEnv = { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue' }; + fakeEnv = { + QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', + }; fakeLog = { info: sandbox.stub(), error: sandbox.stub() }; fakeOpportunity = { getId: () => 'oppty-1' }; + mockIsAuditEnabledForSite = sandbox.stub().resolves(true); + fakeContext = { + site: { + getId: sandbox.stub().returns('site-1'), + }, + dataAccess: { + Configuration: { + findLatest: sandbox.stub().resolves({ + getHandlers: sandbox.stub().returns({ + 'a11y-mystique-auto-fix': { + productCodes: ['test-product-code'], + }, + }), + isHandlerEnabledForSite: mockIsAuditEnabledForSite, + }), + }, + }, + log: { + error: sandbox.stub(), + }, + }; }); afterEach(() => { @@ -2743,7 +2773,7 @@ describe('sendMystiqueMessage', () => { it('should send a message and log info on success', async () => { const result = await generateIndividualOpportunitiesModule.sendMystiqueMessage({ url: 'https://example.com', - issuesList: [{ type: 'color-contrast' }], + issuesList: [{ issueName: 'color-contrast' }], opportunity: fakeOpportunity, siteId: 'site-1', auditId: 'audit-1', @@ -2751,9 +2781,9 @@ describe('sendMystiqueMessage', () => { sqs: fakeSqs, env: fakeEnv, log: fakeLog, + context: fakeContext, }); expect(fakeSqs.sendMessage).to.have.been.calledOnce; - expect(fakeLog.info).to.have.been.calledWithMatch('[A11yIndividual] Sent message to Mystique'); expect(result).to.deep.include({ success: true, url: 'https://example.com' }); }); @@ -2761,7 +2791,7 @@ describe('sendMystiqueMessage', () => { fakeSqs.sendMessage.rejects(new Error('SQS error')); const result = await generateIndividualOpportunitiesModule.sendMystiqueMessage({ url: 'https://example.com', - issuesList: [{ type: 'color-contrast' }], + issuesList: [{ issueName: 'color-contrast' }], opportunity: fakeOpportunity, siteId: 'site-1', auditId: 'audit-1', @@ -2769,23 +2799,107 @@ describe('sendMystiqueMessage', () => { sqs: fakeSqs, env: fakeEnv, log: fakeLog, + context: fakeContext, }); expect(fakeSqs.sendMessage).to.have.been.calledOnce; - expect(fakeLog.error).to.have.been.calledWithMatch('[A11yIndividual][A11yProcessingError] Failed to send message to Mystique'); + expect(fakeLog.error).to.have.been.calledWithMatch('[A11yIndividual][A11yProcessingError] Failed to send message'); expect(result).to.deep.include({ success: false, url: 'https://example.com' }); expect(result.error).to.equal('SQS error'); }); + + it('should use codefix flow when isAuditEnabledForSite returns true for a11y-mystique-auto-fix', async () => { + // Mock isAuditEnabledForSite to return true + const moduleWithMock = await esmock('../../../src/accessibility/utils/generate-individual-opportunities.js', { + '../../../src/common/audit-utils.js': { + isAuditEnabledForSite: sandbox.stub().resolves(true), + }, + }); + + const result = await moduleWithMock.sendMystiqueMessage({ + url: 'https://example.com', + issuesList: [{ issueName: 'button-name' }], // button-name is in issueTypesForCodeFix + opportunity: fakeOpportunity, + siteId: 'site-1', + auditId: 'audit-1', + deliveryType: 'aem_edge', + aggregationKey: 'button-name', + sqs: fakeSqs, + env: fakeEnv, + log: fakeLog, + context: fakeContext, + }); + + // Should send to import worker (codefix flow) + expect(fakeSqs.sendMessage).to.have.been.calledOnce; + const callArgs = fakeSqs.sendMessage.getCall(0).args; + expect(callArgs[0]).to.equal('import-worker-queue'); + expect(callArgs[1]).to.have.property('type', 'code'); + expect(callArgs[1]).to.have.property('forward'); + expect(result.success).to.be.true; + }); + + it('should skip codefix flow when isAuditEnabledForSite returns false for a11y-mystique-auto-fix', async () => { + // Mock isAuditEnabledForSite to return false (entitlement not present) + const moduleWithMock = await esmock('../../../src/accessibility/utils/generate-individual-opportunities.js', { + '../../../src/common/audit-utils.js': { + isAuditEnabledForSite: sandbox.stub().resolves(false), + }, + }); + + const result = await moduleWithMock.sendMystiqueMessage({ + url: 'https://example.com', + issuesList: [{ issueName: 'button-name' }], // button-name is in issueTypesForCodeFix + opportunity: fakeOpportunity, + siteId: 'site-1', + auditId: 'audit-1', + deliveryType: 'aem_edge', + aggregationKey: 'button-name', + sqs: fakeSqs, + env: fakeEnv, + log: fakeLog, + context: fakeContext, + }); + + // Should send directly to Mystique (legacy flow), not to import worker + expect(fakeSqs.sendMessage).to.have.been.calledOnce; + const callArgs = fakeSqs.sendMessage.getCall(0).args; + expect(callArgs[0]).to.equal('test-queue'); // Direct to Mystique queue + expect(callArgs[1]).to.have.property('type', 'guidance:accessibility-remediation'); + expect(callArgs[1]).to.not.have.property('forward'); // No forward in legacy flow + expect(result.success).to.be.true; + }); }); describe('sendMystiqueMessage error path (coverage)', () => { it('should return failure object and log error if sqs.sendMessage rejects', async () => { const fakeSqs = { sendMessage: sinon.stub().rejects(new Error('Simulated SQS failure')) }; - const fakeEnv = { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue' }; + const fakeEnv = { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', IMPORT_WORKER_QUEUE_URL: 'import-queue' }; const fakeLog = { info: sinon.stub(), error: sinon.stub() }; const fakeOpportunity = { getId: () => 'oppty-456' }; + const mockIsAuditEnabledForSite = sinon.stub().resolves(true); + const fakeContext = { + site: { + getId: sinon.stub().returns('site-123'), + }, + dataAccess: { + Configuration: { + findLatest: sinon.stub().resolves({ + getHandlers: sinon.stub().returns({ + 'a11y-mystique-auto-fix': { + productCodes: ['test-product-code'], + }, + }), + isHandlerEnabledForSite: mockIsAuditEnabledForSite, + }), + }, + }, + log: { + error: sinon.stub(), + }, + }; const result = await generateIndividualOpportunitiesModule.sendMystiqueMessage({ url: 'https://example.com', - issuesList: [{ issue_name: 'aria-allowed-attr' }], + issuesList: [{ issueName: 'aria-allowed-attr' }], opportunity: fakeOpportunity, siteId: 'site-123', auditId: 'audit-456', @@ -2793,12 +2907,13 @@ describe('sendMystiqueMessage error path (coverage)', () => { sqs: fakeSqs, env: fakeEnv, log: fakeLog, + context: fakeContext, }); expect(result.success).to.be.false; expect(result.url).to.equal('https://example.com'); expect(result.error).to.equal('Simulated SQS failure'); expect(fakeLog.error).to.have.been.calledWithMatch( - '[A11yIndividual][A11yProcessingError] Failed to send message to Mystique for url https://example.com', + '[A11yIndividual][A11yProcessingError] Failed to send message', ); }); }); @@ -2846,6 +2961,7 @@ describe('sendMessageToMystiqueForRemediation', () => { sendMessage: sandbox.stub().resolves(), }, env: { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', }, }; @@ -2973,6 +3089,7 @@ describe('sendMessageToMystiqueForRemediation', () => { sendMessage: sandbox.stub().resolves(), }, env: { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', }, }; @@ -3098,14 +3215,160 @@ describe('sendMessageToMystiqueForRemediation', () => { mockLog, ); - expect(mockLog.debug).to.have.been.calledWithMatch( - '[A11yIndividual] Sending 1 messages to Mystique queue: test-queue', + expect(mockLog.info).to.have.been.calledWithMatch( + '[A11yIndividual] Sending 1 messages to Mystique (via appropriate flow based on issue types)', ); expect(mockLog.debug).to.have.been.calledWithMatch( '[A11yIndividual] Message sending completed: 1 successful, 0 failed, 0 rejected', ); }); + it('should send messages to import worker with correct structure for code fix issues', async () => { + const sendMessageSpy = sandbox.spy(); + mockContext.sqs.sendMessage = sendMessageSpy; + mockOpportunity.getSuggestions = sandbox.stub().resolves([ + { + getData: () => ({ + url: 'https://example.com/page1', + type: 'url', + issues: [ + { + type: 'aria-allowed-attr', + occurrences: 5, + htmlWithIssues: [ + { + target_selector: 'div[aria-fake]', + }, + ], + }, + ], + }), + getStatus: () => 'NEW', + getId: () => 'suggestion-1', + }, + ]); + + const module = await esmock('../../../src/accessibility/utils/generate-individual-opportunities.js', { + '../../../src/accessibility/guidance-utils/mystique-data-processing.js': { + processSuggestionsForMystique: sandbox.stub().returns([ + { + url: 'https://example.com', + issuesList: [{ issueName: 'aria-allowed-attr' }], // This is in issueTypesForCodeFix + }, + ]), + }, + '../../../src/common/audit-utils.js': { + isAuditEnabledForSite: mockIsAuditEnabledForSite, + }, + }); + + await module.sendMessageToMystiqueForRemediation( + mockOpportunity, + mockContext, + mockLog, + ); + + // Verify message sent to import worker (new flow) + expect(sendMessageSpy).to.have.been.calledOnce; + const [queueUrl, message] = sendMessageSpy.firstCall.args; + + // Verify correct queue URL (import worker for code fix) + expect(queueUrl).to.equal('import-worker-queue'); + + // Verify message structure + expect(message).to.have.property('type', 'code'); + expect(message).to.have.property('siteId', 'site-1'); + expect(message).to.have.property('allowCache', true); + expect(message).to.have.property('data').that.is.an('object'); + expect(message).to.have.property('forward'); + + // Verify forward configuration + expect(message.forward).to.have.property('queue', 'test-queue'); + expect(message.forward).to.have.property('payload'); + + // Verify forward payload structure + const { payload } = message.forward; + expect(payload).to.have.property('type', 'guidance:accessibility-remediation'); + expect(payload).to.have.property('siteId', 'site-1'); + expect(payload).to.have.property('auditId', 'audit-1'); + expect(payload).to.have.property('deliveryType', 'aem_edge'); + expect(payload).to.have.property('data'); + expect(payload.data).to.have.property('url', 'https://example.com'); + expect(payload.data).to.have.property('opportunityId', 'oppty-1'); + expect(payload.data).to.have.property('issuesList').that.is.an('array'); + + // Note: codeBucket and codePath should NOT be in the payload + // They will be added by the import worker + expect(payload.data).to.not.have.property('codeBucket'); + expect(payload.data).to.not.have.property('codePath'); + }); + + it('should send messages directly to Mystique for non-code-fix issues', async () => { + const sendMessageSpy = sandbox.spy(); + mockContext.sqs.sendMessage = sendMessageSpy; + mockOpportunity.getSuggestions = sandbox.stub().resolves([ + { + getData: () => ({ + url: 'https://example.com/page1', + type: 'url', + issues: [ + { + type: 'color-contrast', + occurrences: 5, + htmlWithIssues: [ + { + target_selector: 'div', + }, + ], + }, + ], + }), + getStatus: () => 'NEW', + getId: () => 'suggestion-1', + }, + ]); + + const module = await esmock('../../../src/accessibility/utils/generate-individual-opportunities.js', { + '../../../src/accessibility/guidance-utils/mystique-data-processing.js': { + processSuggestionsForMystique: sandbox.stub().returns([ + { + url: 'https://example.com', + issuesList: [{ issueName: 'color-contrast' }], // This is NOT in issueTypesForCodeFix + }, + ]), + }, + '../../../src/common/audit-utils.js': { + isAuditEnabledForSite: mockIsAuditEnabledForSite, + }, + }); + + await module.sendMessageToMystiqueForRemediation( + mockOpportunity, + mockContext, + mockLog, + ); + + // Verify message sent directly to Mystique (legacy flow) + expect(sendMessageSpy).to.have.been.calledOnce; + const [queueUrl, message] = sendMessageSpy.firstCall.args; + + // Verify correct queue URL (directly to Mystique for legacy flow) + expect(queueUrl).to.equal('test-queue'); + + // Verify message structure (direct Mystique message) + expect(message).to.have.property('type', 'guidance:accessibility-remediation'); + expect(message).to.have.property('siteId', 'site-1'); + expect(message).to.have.property('auditId', 'audit-1'); + expect(message).to.have.property('deliveryType', 'aem_edge'); + expect(message).to.have.property('data'); + expect(message.data).to.have.property('url', 'https://example.com'); + expect(message.data).to.have.property('opportunityId', 'oppty-1'); + expect(message.data).to.have.property('issuesList').that.is.an('array'); + + // In legacy flow, there's no forward configuration + expect(message).to.not.have.property('forward'); + }); + it('should handle errors in main try block and throw with proper logging', async () => { // Make Opportunity.findById throw an error to trigger the catch block (lines 544-546) mockContext.dataAccess.Opportunity.findById = sandbox.stub().rejects(new Error('Database connection lost')); @@ -3145,13 +3408,37 @@ describe('sendMystiqueMessage error handling', () => { it('should handle sendMessage errors and return failure object', async () => { const fakeSqs = { sendMessage: sinon.stub().rejects(new Error('SQS connection failed')) }; - const fakeEnv = { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue' }; + const fakeEnv = { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', + QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', + }; const fakeLog = { info: sinon.stub(), error: sinon.stub() }; const fakeOpportunity = { getId: () => 'oppty-456' }; + const mockIsAuditEnabledForSite = sinon.stub().resolves(false); + const fakeContext = { + site: { + getId: sinon.stub().returns('site-123'), + }, + dataAccess: { + Configuration: { + findLatest: sinon.stub().resolves({ + getHandlers: sinon.stub().returns({ + 'a11y-mystique-auto-fix': { + productCodes: ['test-product-code'], + }, + }), + isHandlerEnabledForSite: mockIsAuditEnabledForSite, + }), + }, + }, + log: { + error: sinon.stub(), + }, + }; const result = await testModule.sendMystiqueMessage({ url: 'https://example.com', - issuesList: [{ issue_name: 'aria-allowed-attr' }], + issuesList: [{ issueName: 'aria-allowed-attr' }], opportunity: fakeOpportunity, siteId: 'site-123', auditId: 'audit-456', @@ -3159,6 +3446,7 @@ describe('sendMystiqueMessage error handling', () => { sqs: fakeSqs, env: fakeEnv, log: fakeLog, + context: fakeContext, }); // Should return failure object @@ -3170,19 +3458,43 @@ describe('sendMystiqueMessage error handling', () => { // Should log the error expect(fakeLog.error).to.have.been.calledWithMatch( - '[A11yIndividual][A11yProcessingError] Failed to send message to Mystique for url https://example.com', + '[A11yIndividual][A11yProcessingError] Failed to send message', ); }); it('should handle sendMessage errors with different URL', async () => { const fakeSqs = { sendMessage: sinon.stub().rejects(new Error('Network error')) }; - const fakeEnv = { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue' }; + const fakeEnv = { + IMPORT_WORKER_QUEUE_URL: 'import-worker-queue', + QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', + }; const fakeLog = { info: sinon.stub(), error: sinon.stub() }; const fakeOpportunity = { getId: () => 'oppty-456' }; + const mockIsAuditEnabledForSite = sinon.stub().resolves(false); + const fakeContext = { + site: { + getId: sinon.stub().returns('site-123'), + }, + dataAccess: { + Configuration: { + findLatest: sinon.stub().resolves({ + getHandlers: sinon.stub().returns({ + 'a11y-mystique-auto-fix': { + productCodes: ['test-product-code'], + }, + }), + isHandlerEnabledForSite: mockIsAuditEnabledForSite, + }), + }, + }, + log: { + error: sinon.stub(), + }, + }; const result = await testModule.sendMystiqueMessage({ url: 'https://test.com', - issuesList: [{ issue_name: 'color-contrast' }], + issuesList: [{ issueName: 'color-contrast' }], opportunity: fakeOpportunity, siteId: 'site-123', auditId: 'audit-456', @@ -3190,6 +3502,7 @@ describe('sendMystiqueMessage error handling', () => { sqs: fakeSqs, env: fakeEnv, log: fakeLog, + context: fakeContext, }); // Should return failure object @@ -3201,7 +3514,7 @@ describe('sendMystiqueMessage error handling', () => { // Should log the error expect(fakeLog.error).to.have.been.calledWithMatch( - '[A11yIndividual][A11yProcessingError] Failed to send message to Mystique for url https://test.com', + '[A11yIndividual][A11yProcessingError] Failed to send message', ); }); }); diff --git a/test/audits/accessibility/mystique-aggregation-e2e.test.js b/test/audits/accessibility/mystique-aggregation-e2e.test.js new file mode 100644 index 000000000..3f3b37e5a --- /dev/null +++ b/test/audits/accessibility/mystique-aggregation-e2e.test.js @@ -0,0 +1,586 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { processSuggestionsForMystique } from '../../../src/accessibility/guidance-utils/mystique-data-processing.js'; + +const { expect } = chai; + +// Configure Chai +chai.use(chaiAsPromised); +chai.use(sinonChai); + +/** + * End-to-end test for accessibility suggestion aggregation flow + * + * This test verifies that suggestions are correctly aggregated into groups + * based on their issue types and granularity strategies before being sent + * to the Mystique queue for remediation. + */ +describe('Accessibility Suggestion Aggregation - End-to-End', () => { + describe('processSuggestionsForMystique - Aggregation Scenario', () => { + it('should aggregate 3 suggestions into 2 groups: 1 button-name and 2 aria-prohibited-attr', () => { + // Create 3 mock suggestions that simulate real accessibility issues + + // Suggestion 1: button-name on page1 + // button-name uses PER_PAGE_PER_COMPONENT granularity: url|type + const suggestion1 = { + getId: () => 'sugg-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + updateFrom: '', + target_selector: 'button.nav', + targetSelector: 'button.nav', + }, + ], + }, + ], + }), + }; + + // Suggestion 2: aria-prohibited-attr on page1 + // aria-prohibited-attr uses PER_TYPE granularity: just the type + const suggestion2 = { + getId: () => 'sugg-2', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [ + { + type: 'aria-prohibited-attr', + description: 'ARIA attribute not allowed on this element', + htmlWithIssues: [ + { + update_from: 'Text', + updateFrom: 'Text', + target_selector: 'span.icon', + targetSelector: 'span.icon', + }, + ], + }, + ], + }), + }; + + // Suggestion 3: aria-prohibited-attr on page2 + // Should be grouped with suggestion 2 due to PER_TYPE granularity + const suggestion3 = { + getId: () => 'sugg-3', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page2', + issues: [ + { + type: 'aria-prohibited-attr', + description: 'ARIA attribute not allowed on this element', + htmlWithIssues: [ + { + update_from: '
Content
', + updateFrom: '
Content
', + target_selector: 'div.content', + targetSelector: 'div.content', + }, + ], + }, + ], + }), + }; + + const suggestions = [suggestion1, suggestion2, suggestion3]; + + // Execute the aggregation function + const result = processSuggestionsForMystique(suggestions); + + // Verify: Should have exactly 2 aggregation groups + expect(result).to.have.length(2); + + // Find the button-name group and aria-prohibited-attr group + const buttonNameGroup = result.find((group) => group.aggregationKey.includes('button-name')); + const ariaProhibitedGroup = result.find((group) => group.aggregationKey === 'aria-prohibited-attr'); + + // Verify button-name group (PER_PAGE_PER_COMPONENT: url|type) + expect(buttonNameGroup).to.exist; + expect(buttonNameGroup.url).to.equal('https://example.com/page1'); + expect(buttonNameGroup.aggregationKey).to.equal('https://example.com/page1|button-name'); + expect(buttonNameGroup.issuesList).to.have.length(1); + expect(buttonNameGroup.issuesList[0]).to.deep.include({ + issueName: 'button-name', + suggestionId: 'sugg-1', + targetSelector: 'button.nav', + faultyLine: '', + issueDescription: 'Buttons must have discernible text', + }); + + // Verify aria-prohibited-attr group (PER_TYPE: just the type) + expect(ariaProhibitedGroup).to.exist; + expect(ariaProhibitedGroup.aggregationKey).to.equal('aria-prohibited-attr'); + // Should have 2 issues: one from page1, one from page2 + expect(ariaProhibitedGroup.issuesList).to.have.length(2); + + // URL should be from the first suggestion in this group + expect(ariaProhibitedGroup.url).to.equal('https://example.com/page1'); + + // Verify both issues are present + const suggestionIds = ariaProhibitedGroup.issuesList.map((issue) => issue.suggestionId); + expect(suggestionIds).to.include.members(['sugg-2', 'sugg-3']); + + // Verify issue details + const issue1 = ariaProhibitedGroup.issuesList.find((i) => i.suggestionId === 'sugg-2'); + expect(issue1).to.deep.include({ + issueName: 'aria-prohibited-attr', + targetSelector: 'span.icon', + faultyLine: 'Text', + issueDescription: 'ARIA attribute not allowed on this element', + }); + + const issue2 = ariaProhibitedGroup.issuesList.find((i) => i.suggestionId === 'sugg-3'); + expect(issue2).to.deep.include({ + issueName: 'aria-prohibited-attr', + targetSelector: 'div.content', + faultyLine: '
Content
', + issueDescription: 'ARIA attribute not allowed on this element', + }); + }); + + it('should handle suggestions from different pages with different aggregation strategies', () => { + // Mix of different granularity types to ensure they're handled correctly + + // button-name: PER_PAGE_PER_COMPONENT (url|type) + const buttonSugg1 = { + getId: () => 'btn-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + const buttonSugg2 = { + getId: () => 'btn-2', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page2', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + // aria-prohibited-attr: PER_TYPE (just type - groups globally) + const ariaSugg1 = { + getId: () => 'aria-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ update_from: '1', target_selector: 'span' }], + }], + }), + }; + + const ariaSugg2 = { + getId: () => 'aria-2', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page2', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ update_from: '2', target_selector: 'span' }], + }], + }), + }; + + const suggestions = [buttonSugg1, buttonSugg2, ariaSugg1, ariaSugg2]; + const result = processSuggestionsForMystique(suggestions); + + // Should have 3 groups: + // 1. button-name on page1 + // 2. button-name on page2 + // 3. aria-prohibited-attr (global - includes both pages) + expect(result).to.have.length(3); + + // Verify button-name groups are separate (PER_PAGE_PER_COMPONENT) + const buttonGroups = result.filter((g) => g.aggregationKey.includes('button-name')); + expect(buttonGroups).to.have.length(2); + expect(buttonGroups[0].issuesList).to.have.length(1); + expect(buttonGroups[1].issuesList).to.have.length(1); + + // Verify aria-prohibited-attr is grouped globally (PER_TYPE) + const ariaGroup = result.find((g) => g.aggregationKey === 'aria-prohibited-attr'); + expect(ariaGroup).to.exist; + expect(ariaGroup.issuesList).to.have.length(2); + }); + + it('should skip suggestions with FIXED or SKIPPED status', () => { + const activeSuggestion = { + getId: () => 'active-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + const fixedSuggestion = { + getId: () => 'fixed-1', + getStatus: () => 'FIXED', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + const skippedSuggestion = { + getId: () => 'skipped-1', + getStatus: () => 'SKIPPED', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + const suggestions = [activeSuggestion, fixedSuggestion, skippedSuggestion]; + const result = processSuggestionsForMystique(suggestions); + + // Should only have 1 group from the active suggestion + expect(result).to.have.length(1); + expect(result[0].issuesList).to.have.length(1); + expect(result[0].issuesList[0].suggestionId).to.equal('active-1'); + }); + + it('should filter out issue types not in issueTypesForMystique', () => { + const mystiqueIssue = { + getId: () => 'mystique-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ update_from: '', target_selector: 'button' }], + }], + }), + }; + + const nonMystiqueIssue = { + getId: () => 'non-mystique-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'color-contrast', // Not in issueTypesForMystique + htmlWithIssues: [{ update_from: '
', target_selector: 'div' }], + }], + }), + }; + + const suggestions = [mystiqueIssue, nonMystiqueIssue]; + const result = processSuggestionsForMystique(suggestions); + + // Should only have 1 group from the Mystique-eligible issue + expect(result).to.have.length(1); + expect(result[0].issuesList[0].issueName).to.equal('button-name'); + }); + }); + + describe('Mystique Message Structure Verification', () => { + it('should verify forward payload structure with all required fields', () => { + // Create suggestions with code-fix eligible issue types + const suggestion = { + getId: () => 'sugg-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + source: 'form1', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + target_selector: 'button.nav', + }, + ], + }, + ], + }), + }; + + const result = processSuggestionsForMystique([suggestion]); + + // Verify the structure + expect(result).to.have.length(1); + const messageData = result[0]; + + // Verify all required fields are present + expect(messageData).to.have.property('url'); + expect(messageData).to.have.property('aggregationKey'); + expect(messageData).to.have.property('issuesList'); + + // Verify field types and values + expect(messageData.url).to.be.a('string'); + expect(messageData.url).to.equal('https://example.com/page1'); + + expect(messageData.aggregationKey).to.be.a('string'); + expect(messageData.aggregationKey).to.include('button-name'); + + expect(messageData.issuesList).to.be.an('array'); + expect(messageData.issuesList).to.have.length(1); + + // Verify issuesList item structure + const issue = messageData.issuesList[0]; + expect(issue).to.have.property('issueName'); + expect(issue).to.have.property('suggestionId'); + expect(issue).to.have.property('faultyLine'); + expect(issue).to.have.property('targetSelector'); + expect(issue).to.have.property('issueDescription'); + expect(issue).to.have.property('url'); + + expect(issue.issueName).to.equal('button-name'); + expect(issue.suggestionId).to.equal('sugg-1'); + expect(issue.url).to.equal('https://example.com/page1'); + }); + + it('should support messages without aggregationKey for backwards compatibility', () => { + // In backwards compatibility mode, aggregationKey might not be required + // The system should still process suggestions and create messages + const suggestion = { + getId: () => 'sugg-legacy', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/legacy', + issues: [ + { + type: 'aria-prohibited-attr', + description: 'ARIA attribute not allowed', + htmlWithIssues: [ + { + update_from: 'Text', + target_selector: 'span.icon', + }, + ], + }, + ], + }), + }; + + const result = processSuggestionsForMystique([suggestion]); + + expect(result).to.have.length(1); + const messageData = result[0]; + + // Even in backwards compatibility mode, the aggregationKey should be generated + expect(messageData).to.have.property('aggregationKey'); + expect(messageData.aggregationKey).to.be.a('string'); + expect(messageData.aggregationKey).to.equal('aria-prohibited-attr'); + + // Verify basic structure is maintained + expect(messageData.url).to.equal('https://example.com/legacy'); + expect(messageData.issuesList).to.have.length(1); + expect(messageData.issuesList[0].suggestionId).to.equal('sugg-legacy'); + }); + + it('should handle multiple issues with the same aggregation key', () => { + // Create suggestions that should be grouped together + const suggestion1 = { + getId: () => 'sugg-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [ + { + type: 'aria-prohibited-attr', + description: 'ARIA attribute not allowed', + htmlWithIssues: [ + { + update_from: 'Text', + target_selector: 'span.icon', + }, + ], + }, + ], + }), + }; + + const suggestion2 = { + getId: () => 'sugg-2', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page2', + issues: [ + { + type: 'aria-prohibited-attr', + description: 'ARIA attribute not allowed', + htmlWithIssues: [ + { + update_from: '
Content
', + target_selector: 'div.content', + }, + ], + }, + ], + }), + }; + + const result = processSuggestionsForMystique([suggestion1, suggestion2]); + + // aria-prohibited-attr uses PER_TYPE granularity, so should be grouped + expect(result).to.have.length(1); + const messageData = result[0]; + + expect(messageData.aggregationKey).to.equal('aria-prohibited-attr'); + expect(messageData.issuesList).to.have.length(2); + + // Verify both issues are present with their suggestionIds + const suggestionIds = messageData.issuesList.map((issue) => issue.suggestionId); + expect(suggestionIds).to.include.members(['sugg-1', 'sugg-2']); + }); + + it('should not process issues that already have guidance', () => { + // Issues with guidance should be filtered out + const suggestionWithGuidance = { + getId: () => 'sugg-with-guidance', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + target_selector: 'button', + guidance: { + generalSuggestion: 'Add text to button', + updateTo: '', + }, + }, + ], + }, + ], + }), + }; + + const suggestionWithoutGuidance = { + getId: () => 'sugg-without-guidance', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + target_selector: 'button.nav', + }, + ], + }, + ], + }), + }; + + const result = processSuggestionsForMystique([suggestionWithGuidance, suggestionWithoutGuidance]); + + // Should only process the suggestion without guidance + expect(result).to.have.length(1); + expect(result[0].issuesList).to.have.length(1); + expect(result[0].issuesList[0].suggestionId).to.equal('sugg-without-guidance'); + }); + + it('should include source in aggregation key when present', () => { + const suggestionWithSource = { + getId: () => 'sugg-with-source', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/contact', + source: 'contact-form', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + target_selector: 'button.submit', + }, + ], + }, + ], + }), + }; + + const suggestionWithoutSource = { + getId: () => 'sugg-without-source', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/contact', + issues: [ + { + type: 'button-name', + description: 'Buttons must have discernible text', + htmlWithIssues: [ + { + update_from: '', + target_selector: 'button.cancel', + }, + ], + }, + ], + }), + }; + + const result = processSuggestionsForMystique([suggestionWithSource, suggestionWithoutSource]); + + // Should create separate groups based on source + expect(result).to.have.length(2); + + const withSourceGroup = result.find((r) => r.aggregationKey.includes('contact-form')); + const withoutSourceGroup = result.find((r) => !r.aggregationKey.includes('contact-form')); + + expect(withSourceGroup).to.exist; + expect(withoutSourceGroup).to.exist; + + expect(withSourceGroup.issuesList[0].suggestionId).to.equal('sugg-with-source'); + expect(withoutSourceGroup.issuesList[0].suggestionId).to.equal('sugg-without-source'); + }); + }); +}); + diff --git a/test/audits/accessibility/mystique-aggregation.test.js b/test/audits/accessibility/mystique-aggregation.test.js new file mode 100644 index 000000000..f1496d73b --- /dev/null +++ b/test/audits/accessibility/mystique-aggregation.test.js @@ -0,0 +1,391 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import * as chai from 'chai'; +import sinon from 'sinon'; +import { processSuggestionsForMystique } from '../../../src/accessibility/guidance-utils/mystique-data-processing.js'; + +const { expect } = chai; + +describe('Mystique Integration with Aggregation Strategies', () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('processSuggestionsForMystique with PER_PAGE_PER_COMPONENT granularity', () => { + it('should group suggestions with same URL and issue type into one message', () => { + const suggestions = [ + { + getId: () => 'sugg-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + update_from: '', + target_selector: 'button.submit', + }], + }], + }), + }, + ]; + + const result = processSuggestionsForMystique(suggestions); + + expect(result[0]).to.have.property('url'); + expect(result[0]).to.have.property('aggregationKey'); + expect(result[0]).to.have.property('issuesList'); + + const item = result[0].issuesList[0]; + expect(item).to.have.property('issueName'); + expect(item).to.have.property('faultyLine'); + expect(item).to.have.property('targetSelector'); + expect(item).to.have.property('issueDescription'); + expect(item).to.have.property('suggestionId'); + + // Verify types + expect(item.issueName).to.be.a('string'); + expect(item.faultyLine).to.be.a('string'); + expect(item.targetSelector).to.be.a('string'); + expect(item.issueDescription).to.be.a('string'); + expect(item.suggestionId).to.be.a('string'); + }); + + it('should handle camelCase and snake_case property names', () => { + const suggestions = [ + { + getId: () => 'sugg-1', + getStatus: () => 'NEW', + getData: () => ({ + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ + update_from: '