From 5fd253ea3ff671f70b3ac83194dbf19c8a3de258 Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Thu, 13 Nov 2025 11:30:11 +0530 Subject: [PATCH 1/6] feat: adding headings check in preflight --- src/headings/handler.js | 86 +++++++++++++++++++---------- src/preflight/handler.js | 4 ++ src/preflight/headings.js | 113 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 src/preflight/headings.js diff --git a/src/headings/handler.js b/src/headings/handler.js index 98e67649e..ecfc9c50c 100644 --- a/src/headings/handler.js +++ b/src/headings/handler.js @@ -224,48 +224,31 @@ async function getBrandGuidelines(healthyTagsObject, log, context) { } /** - * Validate heading semantics for a single page. + * Validate heading semantics for a single page from a scrapeJsonObject. * - Ensure heading level increases by at most 1 when going deeper (no jumps, e.g., h1 → h3) * - Ensure headings are not empty * - * @param {string} url - * @param {Object} log + * @param {string} url - The URL being validated + * @param {Object} scrapeJsonObject - The scraped page data from S3 + * @param {Object} log - Logger instance + * @param {Object} context - Audit context + * @param {Object} seoChecks - SeoChecks instance for tracking healthy tags * @returns {Promise<{url: string, checks: Array}>} */ -export async function validatePageHeadings( +export async function validatePageHeadingFromScrapeJson( url, + scrapeJsonObject, log, - site, - allKeys, - s3Client, - S3_SCRAPER_BUCKET_NAME, context, seoChecks, ) { - if (!url) { - log.error('URL is undefined or null, cannot validate headings'); - return { - url, - checks: [], - }; - } - try { - const scrapeJsonPath = getScrapeJsonPath(url, site.getId()); - const s3Key = allKeys.find((key) => key.includes(scrapeJsonPath)); let document = null; - let scrapeJsonObject = null; - if (!s3Key) { - log.error(`Scrape JSON path not found for ${url}, skipping headings audit`); + if (!scrapeJsonObject) { + log.error(`Scrape JSON object not found for ${url}, skipping headings audit`); return null; } else { - scrapeJsonObject = await getObjectFromKey(s3Client, S3_SCRAPER_BUCKET_NAME, s3Key, log); - if (!scrapeJsonObject) { - log.error(`Scrape JSON object not found for ${url}, skipping headings audit`); - return null; - } else { - document = new JSDOM(scrapeJsonObject.scrapeResult.rawBody).window.document; - } + document = new JSDOM(scrapeJsonObject.scrapeResult.rawBody).window.document; } const pageTags = { @@ -426,6 +409,53 @@ export async function validatePageHeadings( } } +/** + * Validate heading semantics for a single page. + * - Ensure heading level increases by at most 1 when going deeper (no jumps, e.g., h1 → h3) + * - Ensure headings are not empty + * + * @param {string} url + * @param {Object} log + * @returns {Promise<{url: string, checks: Array}>} + */ +export async function validatePageHeadings( + url, + log, + site, + allKeys, + s3Client, + S3_SCRAPER_BUCKET_NAME, + context, + seoChecks, +) { + if (!url) { + log.error('URL is undefined or null, cannot validate headings'); + return { + url, + checks: [], + }; + } + + try { + const scrapeJsonPath = getScrapeJsonPath(url, site.getId()); + const s3Key = allKeys.find((key) => key.includes(scrapeJsonPath)); + let scrapeJsonObject = null; + if (!s3Key) { + log.error(`Scrape JSON path not found for ${url}, skipping headings audit`); + return null; + } else { + scrapeJsonObject = await getObjectFromKey(s3Client, S3_SCRAPER_BUCKET_NAME, s3Key, log); + return validatePageHeadingFromScrapeJson(url, scrapeJsonObject, log, context, seoChecks); + } + } catch (error) { + log.error(`Error validating headings for ${url}: ${error.message}`); + return { + url, + checks: [], + }; + } +} + /** * Main headings audit runner * @param {string} baseURL diff --git a/src/preflight/handler.js b/src/preflight/handler.js index 85e7418c1..adf2fa60b 100644 --- a/src/preflight/handler.js +++ b/src/preflight/handler.js @@ -25,6 +25,7 @@ import metatags from './metatags.js'; import links from './links.js'; import readability from '../readability/handler.js'; import accessibility from './accessibility.js'; +import headings from './headings.js'; const { AUDIT_STEP_DESTINATIONS } = Audit; export const PREFLIGHT_STEP_IDENTIFY = 'identify'; @@ -46,6 +47,7 @@ export const AUDIT_LOREM_IPSUM = 'lorem-ipsum'; export const AUDIT_H1_COUNT = 'h1-count'; export const AUDIT_ACCESSIBILITY = 'accessibility'; export const AUDIT_READABILITY = 'readability'; +export const AUDIT_HEADINGS = 'headings'; const AVAILABLE_CHECKS = [ AUDIT_CANONICAL, @@ -56,6 +58,7 @@ const AVAILABLE_CHECKS = [ AUDIT_H1_COUNT, AUDIT_ACCESSIBILITY, AUDIT_READABILITY, + AUDIT_HEADINGS, ]; export const PREFLIGHT_HANDLERS = { @@ -64,6 +67,7 @@ export const PREFLIGHT_HANDLERS = { links, readability, accessibility, + headings, }; export async function scrapePages(context) { diff --git a/src/preflight/headings.js b/src/preflight/headings.js new file mode 100644 index 000000000..da9fe36f3 --- /dev/null +++ b/src/preflight/headings.js @@ -0,0 +1,113 @@ +/* + * 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 { stripTrailingSlash } from '@adobe/spacecat-shared-utils'; +import { validatePageHeadingFromScrapeJson } from '../headings/handler.js'; +import { saveIntermediateResults } from './utils.js'; +import SeoChecks from '../metatags/seo-checks.js'; + +export const PREFLIGHT_HEADINGS = 'headings'; + +export default async function headings(context, auditContext) { + const { + site, job, log, + } = context; + const { + previewUrls, + step, + audits, + auditsResult, + scrapedObjects, + timeExecutionBreakdown, + } = auditContext; + + const headingsStartTime = Date.now(); + const headingsStartTimestamp = new Date().toISOString(); + + // Create headings audit entries for all pages + previewUrls.forEach((url) => { + const pageResult = audits.get(url); + pageResult.audits.push({ name: PREFLIGHT_HEADINGS, type: 'seo', opportunities: [] }); + }); + + log.debug(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. Starting headings audit`); + + try { + const seoChecks = new SeoChecks(log); + + // Validate headings for each scraped page + const headingsResults = await Promise.all( + scrapedObjects.map(async ({ data }) => { + const scrapeJsonObject = data; + const url = stripTrailingSlash(scrapeJsonObject.finalUrl); + + if (!scrapeJsonObject) { + log.error(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. No scraped data found for ${url}`); + return { url, checks: [] }; + } + + const result = await validatePageHeadingFromScrapeJson( + url, + scrapeJsonObject, + log, + context, + seoChecks, + ); + + return result || { url, checks: [] }; + }), + ); + + // Process results and add to audit opportunities + headingsResults.forEach(({ url, checks }) => { + const audit = audits.get(url)?.audits.find((a) => a.name === PREFLIGHT_HEADINGS); + if (!audit) { + log.warn(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. No audit entry found for ${url}`); + return; + } + + // Add each check as an opportunity + checks.forEach((check) => { + if (!check.success) { + audit.opportunities.push({ + check: check.check, + checkTitle: check.checkTitle, + issue: check.explanation, + seoImpact: 'Moderate', + seoRecommendation: check.suggestion, + ...(check.tagName && { tagName: check.tagName }), + ...(check.count && { count: check.count }), + ...(check.previous && { previous: check.previous }), + ...(check.current && { current: check.current }), + ...(check.transformRules && { transformRules: check.transformRules }), + }); + } + }); + }); + } catch (error) { + log.error(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. Headings audit failed: ${error.message}`); + } + + const headingsEndTime = Date.now(); + const headingsEndTimestamp = new Date().toISOString(); + const headingsElapsed = ((headingsEndTime - headingsStartTime) / 1000).toFixed(2); + log.debug(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. Headings audit completed in ${headingsElapsed} seconds`); + + timeExecutionBreakdown.push({ + name: 'headings', + duration: `${headingsElapsed} seconds`, + startTime: headingsStartTimestamp, + endTime: headingsEndTimestamp, + }); + + await saveIntermediateResults(context, auditsResult, 'headings audit'); +} From 4f24322d1ca02e185f0783ce455acab6f5723c91 Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Thu, 13 Nov 2025 11:33:05 +0530 Subject: [PATCH 2/6] feat: custom dev deployment --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1dda9ef86..640a65299 100755 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "scripts": { "start": "nodemon", - "test": "c8 mocha -i -g 'Post-Deploy' --spec=test/**/*.test.js", + "test": "true", "test-postdeploy": "mocha -g 'Post-Deploy' --spec=test/**/*.test.js", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -20,7 +20,7 @@ "build": "hedy -v --test-bundle", "deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest", "deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest", - "deploy-dev": "hedy -v --deploy --pkgVersion=ci$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", + "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l anagarwa --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h --aws-api vldld6qz1d", "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env", "prepare": "husky", "local-build": "sam build", From 0493ff186ee6d674459ab3429224a3771771052f Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Thu, 13 Nov 2025 22:57:57 +0530 Subject: [PATCH 3/6] feat: AI suggestions generation for headings issues --- src/headings/handler.js | 5 +-- src/preflight/headings.js | 80 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/headings/handler.js b/src/headings/handler.js index ecfc9c50c..65f6a360b 100644 --- a/src/headings/handler.js +++ b/src/headings/handler.js @@ -205,7 +205,7 @@ export async function getH1HeadingASuggestion(url, log, pageTags, context, brand } } -async function getBrandGuidelines(healthyTagsObject, log, context) { +export async function getBrandGuidelines(healthyTagsObject, log, context) { const azureOpenAIClient = AzureOpenAIClient.createFrom(context); const prompt = await getPrompt( { @@ -239,7 +239,6 @@ export async function validatePageHeadingFromScrapeJson( url, scrapeJsonObject, log, - context, seoChecks, ) { try { @@ -445,7 +444,7 @@ export async function validatePageHeadings( return null; } else { scrapeJsonObject = await getObjectFromKey(s3Client, S3_SCRAPER_BUCKET_NAME, s3Key, log); - return validatePageHeadingFromScrapeJson(url, scrapeJsonObject, log, context, seoChecks); + return validatePageHeadingFromScrapeJson(url, scrapeJsonObject, log, seoChecks); } } catch (error) { log.error(`Error validating headings for ${url}: ${error.message}`); diff --git a/src/preflight/headings.js b/src/preflight/headings.js index da9fe36f3..77c46f6d3 100644 --- a/src/preflight/headings.js +++ b/src/preflight/headings.js @@ -11,12 +11,66 @@ */ import { stripTrailingSlash } from '@adobe/spacecat-shared-utils'; -import { validatePageHeadingFromScrapeJson } from '../headings/handler.js'; +import { + validatePageHeadingFromScrapeJson, + getBrandGuidelines, + getH1HeadingASuggestion, + HEADINGS_CHECKS, +} from '../headings/handler.js'; import { saveIntermediateResults } from './utils.js'; import SeoChecks from '../metatags/seo-checks.js'; export const PREFLIGHT_HEADINGS = 'headings'; +/** + * Enhance heading results with AI suggestions for specific check types + * @param {Array} headingsResults - Array of heading validation results + * @param {Object} brandGuidelines - Brand guidelines for AI suggestions + * @param {Object} context - Audit context + * @param {Object} log - Logger instance + * @returns {Promise} Enhanced results with AI suggestions + */ +async function enhanceWithAISuggestions(headingsResults, brandGuidelines, context, log) { + const enhancedResults = await Promise.all( + headingsResults.map(async (pageResult) => { + const { url, checks } = pageResult; + const enhancedChecks = await Promise.all( + checks.map(async (check) => { + if (!check.success) { + const checkType = check.check; + // Generate AI suggestions for H1-related issues only + if (checkType === HEADINGS_CHECKS.HEADING_MISSING_H1.check + || checkType === HEADINGS_CHECKS.HEADING_H1_LENGTH.check + || checkType === HEADINGS_CHECKS.HEADING_EMPTY.check) { + try { + const aiSuggestion = await getH1HeadingASuggestion( + url, + log, + check.pageTags, + context, + brandGuidelines, + ); + if (aiSuggestion) { + return { + ...check, + suggestion: aiSuggestion, + isAISuggested: true, + }; + } + } catch (error) { + log.error(`[preflight-headings] Error generating AI suggestion for ${url}: ${error.message}`); + } + } + } + return check; + }), + ); + return { url, checks: enhancedChecks }; + }), + ); + return enhancedResults; +} + export default async function headings(context, auditContext) { const { site, job, log, @@ -45,7 +99,7 @@ export default async function headings(context, auditContext) { const seoChecks = new SeoChecks(log); // Validate headings for each scraped page - const headingsResults = await Promise.all( + const detectedIssues = await Promise.all( scrapedObjects.map(async ({ data }) => { const scrapeJsonObject = data; const url = stripTrailingSlash(scrapeJsonObject.finalUrl); @@ -59,7 +113,6 @@ export default async function headings(context, auditContext) { url, scrapeJsonObject, log, - context, seoChecks, ); @@ -67,6 +120,26 @@ export default async function headings(context, auditContext) { }), ); + // Enhance with AI suggestions for 'suggest' step + const headingsResults = step === 'suggest' + ? await (async () => { + const healthyTags = seoChecks.getFewHealthyTags(); + const healthyTagsObject = { + title: healthyTags.title.join(', '), + description: healthyTags.description.join(', '), + h1: healthyTags.h1.join(', '), + }; + log.debug(`[preflight-headings] AI Suggestions Healthy tags object: ${JSON.stringify(healthyTagsObject)}`); + try { + const brandGuidelines = await getBrandGuidelines(healthyTagsObject, log, context); + return await enhanceWithAISuggestions(detectedIssues, brandGuidelines, context, log); + } catch (error) { + log.error(`[preflight-headings] Failed to generate AI suggestions: ${error.message}`); + return detectedIssues; + } + })() + : detectedIssues; + // Process results and add to audit opportunities headingsResults.forEach(({ url, checks }) => { const audit = audits.get(url)?.audits.find((a) => a.name === PREFLIGHT_HEADINGS); @@ -84,6 +157,7 @@ export default async function headings(context, auditContext) { issue: check.explanation, seoImpact: 'Moderate', seoRecommendation: check.suggestion, + ...(check.isAISuggested && { isAISuggested: true }), ...(check.tagName && { tagName: check.tagName }), ...(check.count && { count: check.count }), ...(check.previous && { previous: check.previous }), From a3b88eea8784b080f1264129ee2fb71f6067be81 Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Tue, 18 Nov 2025 21:35:16 +0530 Subject: [PATCH 4/6] feat: adding description and seoImpact for all checks --- src/headings/handler.js | 13 ++++++++++++ src/preflight/headings.js | 42 ++++++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/headings/handler.js b/src/headings/handler.js index 65f6a360b..da495ac0a 100644 --- a/src/headings/handler.js +++ b/src/headings/handler.js @@ -31,42 +31,49 @@ export const HEADINGS_CHECKS = Object.freeze({ HEADING_EMPTY: { check: 'heading-empty', title: 'Empty Heading', + description: '{tagName} heading is empty.', explanation: 'Heading elements (H2–H6) should not be empty.', suggestion: 'Add descriptive text or remove the empty heading.', }, HEADING_MISSING_H1: { check: 'heading-missing-h1', title: 'Missing H1 Heading', + description: 'Page does not have an H1 element', explanation: 'Pages should have exactly one H1 element for SEO and accessibility.', suggestion: 'Add an H1 element describing the main content.', }, HEADING_H1_LENGTH: { check: 'heading-h1-length', title: 'H1 Length', + description: `H1 element is either empty or exceeds ${H1_LENGTH_CHARS} characters.`, explanation: `H1 elements should be less than ${H1_LENGTH_CHARS} characters.`, suggestion: `Update the H1 to be less than ${H1_LENGTH_CHARS} characters`, }, HEADING_MULTIPLE_H1: { check: 'heading-multiple-h1', title: 'Multiple H1 Headings', + description: 'Page has more than one H1 element.', explanation: 'Pages should have only one H1 element.', suggestion: 'Change additional H1 elements to H2 or appropriate levels.', }, HEADING_DUPLICATE_TEXT: { check: 'heading-duplicate-text', title: 'Duplicate Heading Text', + description: 'Multiple headings contain identical text.', explanation: 'Headings should have unique text content (WCAG 2.2 2.4.6).', suggestion: 'Ensure each heading has unique, descriptive text.', }, HEADING_ORDER_INVALID: { check: 'heading-order-invalid', title: 'Invalid Heading Order', + description: 'Heading hierarchy skips levels.', explanation: 'Heading levels should increase by one (example: H1→H2), not jump levels (example: H1→H3).', suggestion: 'Adjust heading levels to maintain proper hierarchy.', }, TOPPAGES: { check: 'top-pages', title: 'Top Pages', + description: 'No top pages available for audit', explanation: 'No top pages found', }, }); @@ -270,6 +277,7 @@ export async function validatePageHeadingFromScrapeJson( checks.push({ check: HEADINGS_CHECKS.HEADING_MISSING_H1.check, checkTitle: HEADINGS_CHECKS.HEADING_MISSING_H1.title, + description: HEADINGS_CHECKS.HEADING_MISSING_H1.description, success: false, explanation: HEADINGS_CHECKS.HEADING_MISSING_H1.explanation, suggestion: HEADINGS_CHECKS.HEADING_MISSING_H1.suggestion, @@ -286,6 +294,7 @@ export async function validatePageHeadingFromScrapeJson( checks.push({ check: HEADINGS_CHECKS.HEADING_MULTIPLE_H1.check, checkTitle: HEADINGS_CHECKS.HEADING_MULTIPLE_H1.title, + description: HEADINGS_CHECKS.HEADING_MULTIPLE_H1.description, success: false, explanation: `Found ${h1Elements.length} h1 elements: ${HEADINGS_CHECKS.HEADING_MULTIPLE_H1.explanation}`, suggestion: HEADINGS_CHECKS.HEADING_MULTIPLE_H1.suggestion, @@ -300,6 +309,7 @@ export async function validatePageHeadingFromScrapeJson( checks.push({ check: HEADINGS_CHECKS.HEADING_H1_LENGTH.check, checkTitle: HEADINGS_CHECKS.HEADING_H1_LENGTH.title, + description: HEADINGS_CHECKS.HEADING_H1_LENGTH.description, success: false, explanation: HEADINGS_CHECKS.HEADING_H1_LENGTH.explanation, suggestion: HEADINGS_CHECKS.HEADING_H1_LENGTH.suggestion, @@ -324,6 +334,7 @@ export async function validatePageHeadingFromScrapeJson( return { check: HEADINGS_CHECKS.HEADING_EMPTY.check, checkTitle: HEADINGS_CHECKS.HEADING_EMPTY.title, + description: HEADINGS_CHECKS.HEADING_EMPTY.description.replace('{tagName}', heading.tagName), success: false, explanation: `Found empty text for ${heading.tagName}: ${HEADINGS_CHECKS.HEADING_EMPTY.explanation}`, suggestion: HEADINGS_CHECKS.HEADING_EMPTY.suggestion, @@ -366,6 +377,7 @@ export async function validatePageHeadingFromScrapeJson( checks.push({ check: HEADINGS_CHECKS.HEADING_DUPLICATE_TEXT.check, checkTitle: HEADINGS_CHECKS.HEADING_DUPLICATE_TEXT.title, + description: HEADINGS_CHECKS.HEADING_DUPLICATE_TEXT.description, success: false, explanation: detailedExplanation, suggestion: HEADINGS_CHECKS.HEADING_DUPLICATE_TEXT.suggestion, @@ -387,6 +399,7 @@ export async function validatePageHeadingFromScrapeJson( checks.push({ check: HEADINGS_CHECKS.HEADING_ORDER_INVALID.check, checkTitle: HEADINGS_CHECKS.HEADING_ORDER_INVALID.title, + description: HEADINGS_CHECKS.HEADING_ORDER_INVALID.description, success: false, explanation: HEADINGS_CHECKS.HEADING_ORDER_INVALID.explanation, suggestion: HEADINGS_CHECKS.HEADING_ORDER_INVALID.suggestion, diff --git a/src/preflight/headings.js b/src/preflight/headings.js index 77c46f6d3..3bfe973c8 100644 --- a/src/preflight/headings.js +++ b/src/preflight/headings.js @@ -22,6 +22,21 @@ import SeoChecks from '../metatags/seo-checks.js'; export const PREFLIGHT_HEADINGS = 'headings'; +/** + * Get SEO impact level for a given check type + * @param {string} checkType - The check type identifier + * @returns {string} SEO impact level + */ +function getSeoImpact(checkType) { + const highImpactChecks = [ + HEADINGS_CHECKS.HEADING_MISSING_H1.check, + HEADINGS_CHECKS.HEADING_MULTIPLE_H1.check, + HEADINGS_CHECKS.HEADING_H1_LENGTH.check, + ]; + + return highImpactChecks.includes(checkType) ? 'High' : 'Moderate'; +} + /** * Enhance heading results with AI suggestions for specific check types * @param {Array} headingsResults - Array of heading validation results @@ -151,19 +166,22 @@ export default async function headings(context, auditContext) { // Add each check as an opportunity checks.forEach((check) => { if (!check.success) { - audit.opportunities.push({ + const opportunity = { check: check.check, - checkTitle: check.checkTitle, - issue: check.explanation, - seoImpact: 'Moderate', - seoRecommendation: check.suggestion, - ...(check.isAISuggested && { isAISuggested: true }), - ...(check.tagName && { tagName: check.tagName }), - ...(check.count && { count: check.count }), - ...(check.previous && { previous: check.previous }), - ...(check.current && { current: check.current }), - ...(check.transformRules && { transformRules: check.transformRules }), - }); + issue: check.checkTitle, + issueDetails: check.description, + seoImpact: getSeoImpact(check.check), + seoRecommendation: check.explanation, + }; + + // Add AI suggestion if available + if (check.isAISuggested) { + opportunity.aiSuggestion = check.suggestion; + } else { + opportunity.suggestion = check.suggestion; + } + + audit.opportunities.push(opportunity); } }); }); From aead868bd00f084ceb1772e0553c7f42f5ee516c Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Wed, 19 Nov 2025 15:01:04 +0530 Subject: [PATCH 5/6] fix: reorder headings preflight handler execution sequence --- src/preflight/handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preflight/handler.js b/src/preflight/handler.js index adf2fa60b..23027c9a8 100644 --- a/src/preflight/handler.js +++ b/src/preflight/handler.js @@ -65,9 +65,9 @@ export const PREFLIGHT_HANDLERS = { canonical, metatags, links, + headings, readability, accessibility, - headings, }; export async function scrapePages(context) { From 51c27aea7af59004d5526d31d38d1260ee7e522a Mon Sep 17 00:00:00 2001 From: radhikagpt1208 Date: Thu, 20 Nov 2025 11:47:41 +0530 Subject: [PATCH 6/6] test: adding tests for headings audit --- package.json | 4 +- src/preflight/headings.js | 5 - test/audits/headings.test.js | 100 ++-- test/audits/preflight.test.js | 434 +++++++++++++++++- .../preflight-identify-readability.json | 5 + .../preflight/preflight-identify.json | 5 + test/fixtures/preflight/preflight-suggest.js | 14 + 7 files changed, 516 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 8c7016faf..cb05ef47a 100755 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "scripts": { "start": "nodemon", - "test": "true", + "test": "c8 mocha -i -g 'Post-Deploy' --spec=test/**/*.test.js", "test-postdeploy": "mocha -g 'Post-Deploy' --spec=test/**/*.test.js", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -20,7 +20,7 @@ "build": "hedy -v --test-bundle", "deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest", "deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest", - "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l anagarwa --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h --aws-api vldld6qz1d", + "deploy-dev": "hedy -v --deploy --pkgVersion=ci$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env", "prepare": "husky", "local-build": "sam build", diff --git a/src/preflight/headings.js b/src/preflight/headings.js index 3bfe973c8..6c5c2feed 100644 --- a/src/preflight/headings.js +++ b/src/preflight/headings.js @@ -119,11 +119,6 @@ export default async function headings(context, auditContext) { const scrapeJsonObject = data; const url = stripTrailingSlash(scrapeJsonObject.finalUrl); - if (!scrapeJsonObject) { - log.error(`[preflight-audit] site: ${site.getId()}, job: ${job.getId()}, step: ${step}. No scraped data found for ${url}`); - return { url, checks: [] }; - } - const result = await validatePageHeadingFromScrapeJson( url, scrapeJsonObject, diff --git a/test/audits/headings.test.js b/test/audits/headings.test.js index 8d54afbe3..4485ec308 100644 --- a/test/audits/headings.test.js +++ b/test/audits/headings.test.js @@ -651,6 +651,28 @@ describe('Headings Audit', () => { expect(result).to.be.null; }); + it('handles error in validatePageHeadings when url is invalid', async () => { + const invalidUrl = 'not a valid url'; + const logSpy = sinon.spy(log); + + const result = await validatePageHeadings( + invalidUrl, + logSpy, + site, + allKeys, + s3Client, + context.env.S3_SCRAPER_BUCKET_NAME, + context, + seoChecks, + ); + + expect(result.url).to.equal(invalidUrl); + expect(result.checks).to.deep.equal([]); + expect(logSpy.error).to.have.been.calledWith( + sinon.match(/Error validating headings for/) + ); + }); + it('detects headings with content having child elements', async () => { const baseURL = 'https://example.com'; const url = 'https://example.com/page'; @@ -1094,7 +1116,7 @@ describe('Headings Audit', () => { // Find the missing H1 check const missingH1Check = result.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_MISSING_H1.check); - + expect(missingH1Check).to.exist; expect(missingH1Check.transformRules).to.exist; expect(missingH1Check.transformRules.action).to.equal('insertBefore'); @@ -1129,7 +1151,7 @@ describe('Headings Audit', () => { // Find the missing H1 check const missingH1Check = result.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_MISSING_H1.check); - + expect(missingH1Check).to.exist; expect(missingH1Check.transformRules).to.exist; expect(missingH1Check.transformRules.action).to.equal('insertBefore'); @@ -1164,7 +1186,7 @@ describe('Headings Audit', () => { // Find the H1 length check const h1LengthCheck = result.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_H1_LENGTH.check); - + expect(h1LengthCheck).to.exist; expect(h1LengthCheck.transformRules).to.exist; expect(h1LengthCheck.transformRules.action).to.equal('replace'); @@ -1200,7 +1222,7 @@ describe('Headings Audit', () => { // Find the H1 length check const h1LengthCheck = result.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_H1_LENGTH.check); - + expect(h1LengthCheck).to.exist; expect(h1LengthCheck.transformRules).to.exist; expect(h1LengthCheck.transformRules.action).to.equal('replace'); @@ -1235,7 +1257,7 @@ describe('Headings Audit', () => { // Find the heading order invalid check const orderInvalidCheck = result.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_ORDER_INVALID.check); - + expect(orderInvalidCheck).to.exist; expect(orderInvalidCheck.transformRules).to.be.undefined; }); @@ -1399,7 +1421,7 @@ describe('Headings Audit', () => { const result = generateSuggestions(auditUrl, auditData, context); expect(result.suggestions).to.have.lengthOf(2); - + // Check first suggestion has transformRules const missingH1Suggestion = result.suggestions.find(s => s.checkType === 'heading-missing-h1'); expect(missingH1Suggestion).to.exist; @@ -1554,7 +1576,7 @@ describe('Headings Audit', () => { const result1 = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); const h1LengthCheck1 = result1.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_H1_LENGTH.check); - + // Selector is dynamically generated based on DOM structure expect(h1LengthCheck1.transformRules.selector).to.exist; expect(h1LengthCheck1.transformRules.selector).to.include('h1'); @@ -1580,7 +1602,7 @@ describe('Headings Audit', () => { const result2 = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); const h1LengthCheck2 = result2.checks.find(c => c.check === HEADINGS_CHECKS.HEADING_H1_LENGTH.check); - + // Selector should be different for different DOM structures expect(h1LengthCheck2.transformRules.selector).to.exist; expect(h1LengthCheck2.transformRules.selector).to.include('h1'); @@ -3686,7 +3708,6 @@ describe('Headings Audit', () => { }); }); - describe('convertToOpportunity real function coverage', () => { it('covers comparisonFn execution in real convertToOpportunity function', async () => { const auditUrl = 'https://example.com'; @@ -3764,9 +3785,6 @@ describe('Headings Audit', () => { }); }); - - - describe('getHeadingSelector function', () => { describe('Unit tests - direct function calls', () => { it('returns null when heading is null', () => { @@ -3814,7 +3832,7 @@ describe('Headings Audit', () => { it('generates selector with ID when heading has an ID attribute', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3834,7 +3852,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + // Empty H2 should generate a selector const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; @@ -3845,7 +3863,7 @@ describe('Headings Audit', () => { it('generates selector with single class', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3865,7 +3883,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; expect(emptyCheck.transformRules.selector).to.include('h2'); @@ -3874,7 +3892,7 @@ describe('Headings Audit', () => { it('generates selector with multiple classes (limits to 2)', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3894,7 +3912,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; @@ -3908,7 +3926,7 @@ describe('Headings Audit', () => { it('generates selector with nth-of-type for multiple siblings', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3928,7 +3946,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; expect(emptyCheck.transformRules.selector).to.include('h2'); @@ -3937,7 +3955,7 @@ describe('Headings Audit', () => { it('generates selector with parent context', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3957,7 +3975,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; @@ -3969,7 +3987,7 @@ describe('Headings Audit', () => { it('generates selector with parent classes', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -3989,7 +4007,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; @@ -4001,7 +4019,7 @@ describe('Headings Audit', () => { it('stops at parent with ID (early termination)', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4021,7 +4039,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; @@ -4033,7 +4051,7 @@ describe('Headings Audit', () => { it('limits parent context to 3 levels', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4053,11 +4071,11 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; - + // Count the number of '>' separators (should be max 3 for 3 levels of parents) const separatorCount = (selector.match(/>/g) || []).length; expect(separatorCount).to.be.at.most(3); @@ -4065,7 +4083,7 @@ describe('Headings Audit', () => { it('handles heading with ID and classes (ID takes priority)', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4085,7 +4103,7 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + // Empty H2 should still generate a selector const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; @@ -4094,7 +4112,7 @@ describe('Headings Audit', () => { it('handles complex selector: classes + nth-of-type + parent context', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4114,11 +4132,11 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; - + // Should include all parts expect(selector).to.include('h2'); expect(selector).to.include('title'); // heading class @@ -4130,7 +4148,7 @@ describe('Headings Audit', () => { it('handles empty heading at different document positions', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4150,15 +4168,15 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyChecks = result.checks.filter(c => c.check === 'heading-empty'); expect(emptyChecks).to.have.lengthOf(3); - + // Each should have unique selectors const selectors = emptyChecks.map(c => c.transformRules.selector); const uniqueSelectors = new Set(selectors); expect(uniqueSelectors.size).to.equal(3); - + // Verify each includes its parent context expect(selectors.some(s => s.includes('header'))).to.be.true; expect(selectors.some(s => s.includes('main'))).to.be.true; @@ -4167,7 +4185,7 @@ describe('Headings Audit', () => { it('handles parent with excessive classes (limits to 2)', async () => { const url = 'https://example.com/page'; - + s3Client.send.resolves({ Body: { transformToString: () => JSON.stringify({ @@ -4187,11 +4205,11 @@ describe('Headings Audit', () => { }); const result = await validatePageHeadings(url, log, site, allKeys, s3Client, context.env.S3_SCRAPER_BUCKET_NAME, context, seoChecks); - + const emptyCheck = result.checks.find(c => c.check === 'heading-empty'); expect(emptyCheck).to.exist; const selector = emptyCheck.transformRules.selector; - + // Should include first 2 parent classes only expect(selector).to.include('container'); expect(selector).to.include('wrapper'); diff --git a/test/audits/preflight.test.js b/test/audits/preflight.test.js index 4515a5ece..6e335894d 100644 --- a/test/audits/preflight.test.js +++ b/test/audits/preflight.test.js @@ -666,6 +666,7 @@ describe('Preflight Audit', () => { 'metatags-preflight': { productCodes: ['aem-sites'] }, 'canonical-preflight': { productCodes: ['aem-sites'] }, 'links-preflight': { productCodes: ['aem-sites'] }, + 'headings-preflight': { productCodes: ['aem-sites'] }, 'body-size-preflight': { productCodes: ['aem-sites'] }, 'lorem-ipsum-preflight': { productCodes: ['aem-sites'] }, 'h1-count-preflight': { productCodes: ['aem-sites'] }, @@ -865,6 +866,8 @@ describe('Preflight Audit', () => { // Mock metatags-preflight audit as enabled configuration.isHandlerEnabledForSite.withArgs('metatags-preflight', site).returns(true); + // Mock headings-preflight audit as enabled + configuration.isHandlerEnabledForSite.withArgs('headings-preflight', site).returns(true); // Mock readability-preflight audit as disabled configuration.isHandlerEnabledForSite.withArgs('readability-preflight', site).returns(false); // All other audits should be enabled by default @@ -912,6 +915,7 @@ describe('Preflight Audit', () => { configuration.isHandlerEnabledForSite.withArgs('preflight', site).returns(true); configuration.isHandlerEnabledForSite.withArgs('metatags-preflight', site).returns(true); + configuration.isHandlerEnabledForSite.withArgs('headings-preflight', site).returns(true); configuration.isHandlerEnabledForSite.returns(false); await preflightAuditFunction(context); expect(genvarClient.generateSuggestions).to.have.been.called; @@ -1209,7 +1213,7 @@ describe('Preflight Audit', () => { // Verify breakdown structure const { breakdown } = pageResult.profiling; - const expectedChecks = ['dom', 'canonical', 'metatags', 'links', 'readability']; + const expectedChecks = ['dom', 'canonical', 'metatags', 'links', 'headings', 'readability']; expect(breakdown).to.be.an('array'); expect(breakdown).to.have.lengthOf(expectedChecks.length); @@ -1228,9 +1232,9 @@ describe('Preflight Audit', () => { await preflightAuditFunction(context); // Verify that AsyncJob.findById was called for job metadata update, each intermediate save and final save - // (total of 7 times: 1 metadata update + 5 intermediate + 1 final) + // (total of 7 times: 1 metadata update + 6 intermediate + 1 final) expect(context.dataAccess.AsyncJob.findById).to.have.been.called; - expect(context.dataAccess.AsyncJob.findById.callCount).to.equal(7); + expect(context.dataAccess.AsyncJob.findById.callCount).to.equal(8); }); it('handles errors during intermediate saves gracefully', async () => { @@ -1566,6 +1570,7 @@ describe('Preflight Audit', () => { '../../src/preflight/canonical.js': { default: async () => undefined }, '../../src/preflight/metatags.js': { default: async () => undefined }, '../../src/preflight/links.js': { default: async () => undefined }, + '../../src/preflight/headings.js': { default: async () => undefined }, '../../src/readability/handler.js': { default: async () => undefined }, '../../src/preflight/accessibility.js': { default: async () => undefined }, '@adobe/spacecat-shared-ims-client': { @@ -3798,6 +3803,428 @@ describe('Preflight Audit', () => { }); }); + describe('headings audit', () => { + let context; + let auditContext; + let sandbox; + let headings; + let getH1HeadingASuggestionStub; + let getBrandGuidelinesStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + + const log = { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + }; + + context = { + site: { + getId: sinon.stub().returns('site-123'), + getBaseURL: sinon.stub().returns('https://example.com'), + }, + job: { + getId: sinon.stub().returns('job-123'), + getMetadata: sinon.stub().returns({ + payload: { enableAuthentication: false }, + }), + }, + log, + env: { + S3_SCRAPER_BUCKET_NAME: 'test-bucket', + }, + dataAccess: { + AsyncJob: { + update: sinon.stub().resolves(), + }, + }, + }; + + auditContext = { + previewUrls: ['https://example.com/page1', 'https://example.com/page2'], + step: 'suggest', + audits: new Map([ + ['https://example.com/page1', { + audits: [{ + name: 'headings', + type: 'seo', + opportunities: [], + }], + }], + ['https://example.com/page2', { + audits: [{ + name: 'headings', + type: 'seo', + opportunities: [], + }], + }], + ]), + auditsResult: {}, + scrapedObjects: [ + { + data: { + finalUrl: 'https://example.com/page1', + scrapeResult: { + rawBody: '

Test H1

Another H1

', + tags: { + title: 'Page 1 Title', + description: 'Page 1 Description', + h1: ['Test H1', 'Another H1'], + }, + }, + }, + }, + { + data: { + finalUrl: 'https://example.com/page2', + scrapeResult: { + rawBody: '', + tags: { + title: 'Page 2 Title', + description: 'Page 2 Description', + h1: [], + }, + }, + }, + }, + ], + timeExecutionBreakdown: [], + }; + + // Create stubs for the functions we want to mock + getH1HeadingASuggestionStub = sinon.stub(); + getBrandGuidelinesStub = sinon.stub(); + + // Mock the headings handler with stubbed functions + const headingsModule = await esmock('../../src/preflight/headings.js', { + '../../src/headings/handler.js': { + validatePageHeadingFromScrapeJson: async (url, scrapeJsonObject, log, seoChecks) => { + // Return validation results based on the scraped data + if (url === 'https://example.com/page1') { + return { + url, + checks: [ + { + check: 'heading-multiple-h1', + checkTitle: 'Multiple H1 Tags', + description: 'Page has multiple H1 tags', + explanation: 'A page should have only one H1 tag', + success: false, + pageTags: { h1: ['Test H1', 'Another H1'] }, + }, + ], + }; + } + if (url === 'https://example.com/page2') { + return { + url, + checks: [ + { + check: 'heading-missing-h1', + checkTitle: 'Missing H1 Tag', + description: 'Page is missing an H1 tag', + explanation: 'Every page should have an H1 tag', + success: false, + pageTags: { h1: [] }, + }, + ], + }; + } + return { url, checks: [] }; + }, + getBrandGuidelines: getBrandGuidelinesStub, + getH1HeadingASuggestion: getH1HeadingASuggestionStub, + HEADINGS_CHECKS: { + HEADING_MISSING_H1: { check: 'heading-missing-h1' }, + HEADING_MULTIPLE_H1: { check: 'heading-multiple-h1' }, + HEADING_H1_LENGTH: { check: 'heading-h1-length' }, + HEADING_EMPTY: { check: 'heading-empty' }, + }, + }, + '../../src/preflight/utils.js': { + saveIntermediateResults: sinon.stub().resolves(), + }, + }); + + headings = headingsModule.default; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should handle missing scraped data for a URL', async () => { + auditContext.scrapedObjects = [ + { + data: null, + }, + { + data: { + finalUrl: 'https://example.com/page2', + scrapeResult: { + rawBody: '', + tags: { + title: 'Page 2 Title', + description: 'Page 2 Description', + h1: [], + }, + }, + }, + }, + ]; + + await headings(context, auditContext); + expect(context.log.error).to.have.been.calledWithMatch( + sinon.match(/Headings audit failed/), + ); + }); + + it('should handle missing audit entry for a URL', async () => { + const modifiedAuditContext = { + previewUrls: ['https://example.com/page1'], + step: 'identify', + audits: new Map([ + ['https://example.com/page1', { + audits: [], + }], + // page2 is NOT in the audits map since it's not in previewUrls + ]), + auditsResult: {}, + scrapedObjects: [ + { + data: { + finalUrl: 'https://example.com/page1', + scrapeResult: { + rawBody: '

Test

', + tags: { title: 'Title', description: 'Desc', h1: ['Test'] }, + }, + }, + }, + { + data: { + finalUrl: 'https://example.com/page2', + scrapeResult: { + rawBody: '', + tags: { title: 'Title 2', description: 'Desc 2', h1: [] }, + }, + }, + }, + ], + timeExecutionBreakdown: [], + }; + + it('should skip AI enhancement during identify step', async () => { + // Change step to 'identify' + auditContext.step = 'identify'; + + await headings(context, auditContext); + + // Verify that getBrandGuidelines was never called (no AI enhancement) + expect(getBrandGuidelinesStub).to.not.have.been.called; + expect(getH1HeadingASuggestionStub).to.not.have.been.called; + }); + + + await headings(context, modifiedAuditContext); + expect(context.log.warn).to.have.been.called; + const warnCalls = context.log.warn.getCalls(); + const relevantCall = warnCalls.find(call => + call.args[0] && call.args[0].includes('No audit entry found for https://example.com/page2') + ); + expect(relevantCall).to.exist; + }); + + it('should add aiSuggestion when AI suggestion is available', async () => { + getBrandGuidelinesStub.resolves({ brandName: 'Test Brand' }); + getH1HeadingASuggestionStub.resolves('AI-generated H1 suggestion'); + + await headings(context, auditContext); + const page2Audit = auditContext.audits.get('https://example.com/page2') + .audits.find((a) => a.name === 'headings'); + + const opportunityWithAI = page2Audit.opportunities.find((opp) => opp.aiSuggestion); + expect(opportunityWithAI).to.exist; + expect(opportunityWithAI.aiSuggestion).to.equal('AI-generated H1 suggestion'); + }); + + it('should handle AI suggestion error gracefully during suggest step', async () => { + getBrandGuidelinesStub.resolves({ brandName: 'Test Brand' }); + getH1HeadingASuggestionStub.rejects(new Error('Service unavailable')); + + await headings(context, auditContext); + expect(context.log.error).to.have.been.calledWithMatch( + sinon.match(/Error generating AI suggestion for https:\/\/example\.com\/page2/), + ); + + // Verify that opportunities were still added despite AI error + const page2Audit = auditContext.audits.get('https://example.com/page2') + .audits.find((a) => a.name === 'headings'); + expect(page2Audit.opportunities).to.have.length.greaterThan(0); + }); + + it('should add suggestion when AI suggestion is not available', async () => { + getBrandGuidelinesStub.resolves({ brandName: 'Test Brand' }); + getH1HeadingASuggestionStub.resolves(null); + + await headings(context, auditContext); + const page2Audit = auditContext.audits.get('https://example.com/page2') + .audits.find((a) => a.name === 'headings'); + + page2Audit.opportunities.forEach((opp) => { + expect(opp).to.not.have.property('aiSuggestion'); + expect(opp).to.have.property('suggestion'); + }); + }); + + it('should handle getBrandGuidelines error gracefully', async () => { + getBrandGuidelinesStub.rejects(new Error('Brand guidelines fetch failed')); + + await headings(context, auditContext); + expect(context.log.error).to.have.been.calledWithMatch( + sinon.match(/Failed to generate AI suggestions/), + ); + + // Verify that opportunities were still added despite the error + const page1Audit = auditContext.audits.get('https://example.com/page1') + .audits.find((a) => a.name === 'headings'); + const page2Audit = auditContext.audits.get('https://example.com/page2') + .audits.find((a) => a.name === 'headings'); + + expect(page1Audit.opportunities).to.have.length.greaterThan(0); + expect(page2Audit.opportunities).to.have.length.greaterThan(0); + }); + + it('should return Moderate seoImpact for non-high-impact check types', async () => { + const headingsModuleWithModerateCheck = await esmock('../../src/preflight/headings.js', { + '../../src/headings/handler.js': { + validatePageHeadingFromScrapeJson: async (url) => { + return { + url, + checks: [ + { + check: 'heading-order-invalid', + checkTitle: 'Invalid Heading Order', + description: 'Headings are not in proper order', + explanation: 'Headings should be in sequential order', + success: false, + pageTags: { h1: ['Test'] }, + }, + ], + }; + }, + getBrandGuidelines: getBrandGuidelinesStub, + getH1HeadingASuggestion: getH1HeadingASuggestionStub, + HEADINGS_CHECKS: { + HEADING_MISSING_H1: { check: 'heading-missing-h1' }, + HEADING_MULTIPLE_H1: { check: 'heading-multiple-h1' }, + HEADING_H1_LENGTH: { check: 'heading-h1-length' }, + HEADING_EMPTY: { check: 'heading-empty' }, + }, + }, + '../../src/preflight/utils.js': { + saveIntermediateResults: sinon.stub().resolves(), + }, + }); + + const testAuditContext = { + previewUrls: ['https://example.com/test'], + step: 'identify', + audits: new Map([ + ['https://example.com/test', { + audits: [{ + name: 'headings', + type: 'seo', + opportunities: [], + }], + }], + ]), + auditsResult: {}, + scrapedObjects: [ + { + data: { + finalUrl: 'https://example.com/test', + scrapeResult: { + rawBody: '

Test

Skipped H2

', + tags: { title: 'Test', description: 'Test', h1: ['Test'] }, + }, + }, + }, + ], + timeExecutionBreakdown: [], + }; + + await headingsModuleWithModerateCheck.default(context, testAuditContext); + + // Verify the opportunity was added with 'Moderate' seoImpact + const testAudit = testAuditContext.audits.get('https://example.com/test') + .audits.find((a) => a.name === 'headings'); + + expect(testAudit.opportunities).to.have.lengthOf(1); + expect(testAudit.opportunities[0].seoImpact).to.equal('Moderate'); + }); + + it('should handle validatePageHeadingFromScrapeJson returning falsy value', async () => { + const headingsModuleWithNullReturn = await esmock('../../src/preflight/headings.js', { + '../../src/headings/handler.js': { + validatePageHeadingFromScrapeJson: async (url) => { + return null; + }, + getBrandGuidelines: getBrandGuidelinesStub, + getH1HeadingASuggestion: getH1HeadingASuggestionStub, + HEADINGS_CHECKS: { + HEADING_MISSING_H1: { check: 'heading-missing-h1' }, + HEADING_MULTIPLE_H1: { check: 'heading-multiple-h1' }, + HEADING_H1_LENGTH: { check: 'heading-h1-length' }, + HEADING_EMPTY: { check: 'heading-empty' }, + }, + }, + '../../src/preflight/utils.js': { + saveIntermediateResults: sinon.stub().resolves(), + }, + }); + + const testAuditContext = { + previewUrls: ['https://example.com/test'], + step: 'identify', + audits: new Map([ + ['https://example.com/test', { + audits: [{ + name: 'headings', + type: 'seo', + opportunities: [], + }], + }], + ]), + auditsResult: {}, + scrapedObjects: [ + { + data: { + finalUrl: 'https://example.com/test', + scrapeResult: { + rawBody: '

Test

', + tags: { title: 'Test', description: 'Test', h1: ['Test'] }, + }, + }, + }, + ], + timeExecutionBreakdown: [], + }; + + await headingsModuleWithNullReturn.default(context, testAuditContext); + + // The function should complete successfully even when validation returns null + // The fallback { url, checks: [] } is used, so no opportunities are added + const testAudit = testAuditContext.audits.get('https://example.com/test') + .audits.find((a) => a.name === 'headings'); + + expect(testAudit.opportunities).to.have.lengthOf(0); + expect(context.log.debug).to.have.been.called; + }); + }); + describe('saving enabled checks in job metadata payload', () => { let site; let job; @@ -3859,6 +4286,7 @@ describe('Preflight Audit', () => { 'metatags-preflight': { productCodes: ['aem-sites'] }, 'canonical-preflight': { productCodes: ['aem-sites'] }, 'links-preflight': { productCodes: ['aem-sites'] }, + 'headings-preflight': { productCodes: ['aem-sites'] }, 'body-size-preflight': { productCodes: ['aem-sites'] }, 'lorem-ipsum-preflight': { productCodes: ['aem-sites'] }, 'h1-count-preflight': { productCodes: ['aem-sites'] }, diff --git a/test/fixtures/preflight/preflight-identify-readability.json b/test/fixtures/preflight/preflight-identify-readability.json index dc3c350dc..ca9ec72ef 100644 --- a/test/fixtures/preflight/preflight-identify-readability.json +++ b/test/fixtures/preflight/preflight-identify-readability.json @@ -47,6 +47,11 @@ "type": "seo", "opportunities": [] }, + { + "name": "headings", + "type": "seo", + "opportunities": [] + }, { "name": "readability", "type": "seo", diff --git a/test/fixtures/preflight/preflight-identify.json b/test/fixtures/preflight/preflight-identify.json index 9bf481997..c4cf3895a 100644 --- a/test/fixtures/preflight/preflight-identify.json +++ b/test/fixtures/preflight/preflight-identify.json @@ -95,6 +95,11 @@ } ] }, + { + "name": "headings", + "opportunities": [], + "type": "seo" + }, { "name": "readability", "type": "seo", diff --git a/test/fixtures/preflight/preflight-suggest.js b/test/fixtures/preflight/preflight-suggest.js index 1be30909f..0b622eb3e 100644 --- a/test/fixtures/preflight/preflight-suggest.js +++ b/test/fixtures/preflight/preflight-suggest.js @@ -87,6 +87,20 @@ export const suggestionData = [ }, ], }, + { + name: 'headings', + type: 'seo', + opportunities: [ + { + check: 'heading-multiple-h1', + issue: 'Multiple H1 Headings', + issueDetails: 'Page has more than one H1 element.', + seoImpact: 'High', + seoRecommendation: 'Found 2 h1 elements: Pages should have only one H1 element.', + suggestion: 'Change additional H1 elements to H2 or appropriate levels.', + }, + ], + }, { name: 'readability', type: 'seo',