Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions src/accessibility/auto-optimization-handlers/codefix-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/*
* 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 { 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<Object|null>} - 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>} - 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
*
* Expected message format:
* {
* "siteId": "<site-id>",
* "type": "codefix:accessibility",
* "data": {
* "opportunityId": "<uuid>",
* "updates": [
* {
* "url": "<page url>",
* "source": "<source>", // optional
* "type": ["color-contrast", "select-name"]
* }
* ]
* }
* }
*
* @param {Object} message - The SQS message
* @param {Object} context - The context object containing dataAccess, log, s3Client, etc.
* @returns {Promise<Response>} - HTTP response
*/
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;
}));
}));

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}`);
}
}
86 changes: 86 additions & 0 deletions src/accessibility/utils/data-processing.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,3 +754,89 @@ export async function sendRunImportMessage(
...(data && { data }),
});
}

/**
* 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
* @returns {Promise<void>}
*/
export async function sendCodeFixMessagesToImporter(opportunity, auditId, context) {
const {
log, sqs, env, site,
} = context;

const siteId = opportunity.getSiteId();
const baseUrl = site.getBaseURL();
const opportunityType = opportunity.getType();

try {
// Get all suggestions from the opportunity
const suggestions = await opportunity.getSuggestions();
if (!suggestions || suggestions.length === 0) {
log.info(`[${opportunityType}] [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(`[${opportunityType}] [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,
type: `codefix:${opportunityType}`,
siteId,
auditId,
url: baseUrl,
deliveryType: site.getDeliveryType(),
data: {
opportunityId: opportunity.getId(),
suggestionIds: group.suggestionIds,
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the forward message has the structure

forward: { queue: ..., data : {}}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No forward field exists currently.

},
};

try {
await sqs.sendMessage(env.IMPORT_WORKER_QUEUE_URL, message);
log.info(`[${opportunityType}] [Site Id: ${siteId}] Sent code-fix message to importer for URL: ${group.url}, source: ${group.source}, issueType: ${group.issueType}, suggestions: ${group.suggestionIds.length}`);
} catch (error) {
log.error(`[${opportunityType}] [Site Id: ${siteId}] Failed to send code-fix message for URL: ${group.url}, error: ${error.message}`);
}
});

await Promise.all(messagePromises);
log.info(`[${opportunityType}] [Site Id: ${siteId}] Completed sending ${messagePromises.length} code-fix messages to importer`);
} catch (error) {
log.error(`[${opportunityType}] [Site Id: ${siteId}] Error in sendCodeFixMessagesToImporter: ${error.message}`);
}
}
23 changes: 20 additions & 3 deletions src/forms-opportunities/oppty-handlers/accessibility-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
import { ok } from '@adobe/spacecat-shared-http-utils';
import { Audit } from '@adobe/spacecat-shared-data-access';
import { FORM_OPPORTUNITY_TYPES, formOpportunitiesMap } from '../constants.js';
import { getSuccessCriteriaDetails, sendMessageToFormsQualityAgent, sendMessageToMystiqueForGuidance } from '../utils.js';
import {
getSuccessCriteriaDetails,
sendMessageToFormsQualityAgent,
sendMessageToMystiqueForGuidance,
} from '../utils.js';
import { updateStatusToIgnored } from '../../accessibility/utils/scrape-utils.js';
import {
aggregateAccessibilityIssues,
createIndividualOpportunitySuggestions,
} from '../../accessibility/utils/generate-individual-opportunities.js';
import { aggregateAccessibilityData, sendRunImportMessage } from '../../accessibility/utils/data-processing.js';
import { aggregateAccessibilityData, sendRunImportMessage, sendCodeFixMessagesToImporter } from '../../accessibility/utils/data-processing.js';
import { URL_SOURCE_SEPARATOR, A11Y_METRICS_AGGREGATOR_IMPORT_TYPE, WCAG_CRITERIA_COUNTS } from '../../accessibility/utils/constants.js';
import { isAuditEnabledForSite } from '../../common/audit-utils.js';

const filterAccessibilityOpportunities = (opportunities) => opportunities.filter((opportunity) => opportunity.getTags()?.includes('Forms Accessibility'));

Expand Down Expand Up @@ -443,7 +448,7 @@ export async function createAccessibilityOpportunity(auditData, context) {
}

export default async function handler(message, context) {
const { log } = context;
const { log, site } = context;
const { auditId, siteId, data } = message;
const { opportunityId, a11y } = data;
log.debug(`[Form Opportunity] [Site Id: ${siteId}] Received message in accessibility handler: ${JSON.stringify(message, null, 2)}`);
Expand All @@ -463,6 +468,18 @@ export default async function handler(message, context) {
// Create individual suggestions from Mystique data
await createFormAccessibilitySuggestionsFromMystique(a11y, opportunity, context);

// send message to importer for code-fix generation
const isAutoFixEnabled = await isAuditEnabledForSite(`${opportunity.getType()}-auto-fix`, site, context);
if (isAutoFixEnabled) {
await sendCodeFixMessagesToImporter(
opportunity,
auditId,
context,
);
} else {
log.info(`[Form Opportunity] [Site Id: ${siteId}] ${opportunity.getType()}-auto-fix is disabled for site, skipping code-fix generation`);
}

log.info(`[Form Opportunity] [Site Id: ${siteId}] a11y opportunity: ${JSON.stringify(opportunity, null, 2)}`);
const opportunityData = opportunity.getData();
const a11yData = opportunityData.accessibility;
Expand Down
Loading