diff --git a/src/cwv/kpi-metrics.js b/src/cwv/kpi-metrics.js index 24231a16d8..e18716e030 100644 --- a/src/cwv/kpi-metrics.js +++ b/src/cwv/kpi-metrics.js @@ -12,12 +12,12 @@ import resolveCpcValue from './cpc-value-resolver.js'; -const METRICS = ['lcp', 'cls', 'inp']; +export const METRICS = ['lcp', 'cls', 'inp']; /** * Thresholds for "green" metrics */ -const THRESHOLDS = { +export const THRESHOLDS = { lcp: 2500, cls: 0.1, inp: 200, @@ -135,4 +135,18 @@ const calculateKpiDeltasForAudit = (auditData, dataAccess, groupedURLs) => { ); }; +/** + * Calculates the confidence score for a single CWV entry as the sum of + * projected traffic lost across all device types. + * Higher score = more expected impact from fixing CWV issues on this page. + * @param {Object} entry - CWV audit entry with a metrics array. + * @returns {number} Total projected traffic lost across all devices. + */ +export function calculateConfidenceScore(entry) { + return entry.metrics.reduce( + (total, deviceMetrics) => total + calculateProjectedTrafficLost(deviceMetrics), + 0, + ); +} + export default calculateKpiDeltasForAudit; diff --git a/src/cwv/opportunity-sync.js b/src/cwv/opportunity-sync.js index 621515f6a0..54ae0e341a 100644 --- a/src/cwv/opportunity-sync.js +++ b/src/cwv/opportunity-sync.js @@ -14,7 +14,20 @@ import { Audit } from '@adobe/spacecat-shared-data-access'; import { syncSuggestions } from '../utils/data-access.js'; import { createOpportunityData } from './opportunity-data-mapper.js'; import { convertToOpportunity } from '../common/opportunity.js'; -import calculateKpiDeltasForAudit from './kpi-metrics.js'; +import calculateKpiDeltasForAudit, { THRESHOLDS, METRICS, calculateConfidenceScore } from './kpi-metrics.js'; + +/** + * Returns true if the CWV entry has at least one metric that exceeds the "good" threshold + * on any device type. Null/undefined metric values are treated as passing (no data = not failing). + * @param {Object} entry - CWV audit entry ({ metrics: [{lcp, cls, inp, ...}] }) + * @returns {boolean} + */ +function hasFailingMetrics(entry) { + return entry.metrics.some((deviceMetrics) => METRICS.some((metric) => { + const value = deviceMetrics[metric]; + return value !== null && value !== undefined && value > THRESHOLDS[metric]; + })); +} /** * Synchronizes opportunities and suggestions for a CWV audit @@ -24,12 +37,18 @@ import calculateKpiDeltasForAudit from './kpi-metrics.js'; */ export async function syncOpportunitiesAndSuggestions(context) { const { - site, audit, finalUrl, + site, audit, finalUrl, log, } = context; const auditResult = audit.getAuditResult(); const groupedURLs = site.getConfig().getGroupedURLs(Audit.AUDIT_TYPES.CWV); + // Only sync suggestions for pages where at least one CWV metric is failing. + // Pages where all metrics pass are not actionable. Data is already sorted by + // page views descending from step 1. + const cwvData = auditResult.cwv.filter(hasFailingMetrics); + log.info(`[syncOpportunitiesAndSuggestions] site ${site.getId()} - ${cwvData.length} of ${auditResult.cwv.length} CWV entries have failing metrics`); + // Build minimal audit data object for opportunity creation const auditData = { siteId: site.getId(), @@ -49,13 +68,14 @@ export async function syncOpportunitiesAndSuggestions(context) { // Sync suggestions const buildKey = (data) => (data.type === 'url' ? data.url : data.pattern); - const maxOrganicForUrls = Math.max( - ...auditResult.cwv.filter((entry) => entry.type === 'url').map((entry) => entry.pageviews), + const maxConfidenceForUrls = Math.max( + 0, + ...cwvData.filter((entry) => entry.type === 'url').map((entry) => calculateConfidenceScore(entry)), ); await syncSuggestions({ opportunity, - newData: auditResult.cwv, + newData: cwvData, context, buildKey, bypassValidationForPlg: true, @@ -63,11 +83,13 @@ export async function syncOpportunitiesAndSuggestions(context) { opportunityId: opportunity.getId(), type: 'CODE_CHANGE', // the rank logic for CWV is as follows: - // 1. if the entry is a group, then the rank is the max organic for URLs - // plus the organic for the group - // 2. if the entry is a URL, then the rank is the max organic for URLs - // Reason is because UI first shows groups and then URLs - rank: entry.type === 'group' ? maxOrganicForUrls + entry.organic : entry.organic, + // 1. if the entry is a group, then the rank is the max confidence for URLs + // plus the confidence for the group (ensures groups sort before URLs, + // because the UI shows groups first) + // 2. if the entry is a URL, then the rank is the confidence score for that URL + rank: entry.type === 'group' + ? maxConfidenceForUrls + calculateConfidenceScore(entry) + : calculateConfidenceScore(entry), data: { ...entry, jiraLink: '', diff --git a/test/audits/cwv.test.js b/test/audits/cwv.test.js index f77f37cd8d..32db1b3ee7 100644 --- a/test/audits/cwv.test.js +++ b/test/audits/cwv.test.js @@ -68,7 +68,7 @@ describe('collectCWVDataAndImportCode Tests', () => { productCodes: ['aem-sites'], }, }), - isHandlerEnabledForSite: () => true, + isHandlerEnabledForSite: (handler) => handler !== 'summit-plg', }), }, }, @@ -330,6 +330,17 @@ describe('collectCWVDataAndImportCode Tests', () => { warn: sandbox.stub(), }; + context.dataAccess.Configuration = { + findLatest: sandbox.stub().resolves({ + getHandlers: () => ({ + 'cwv-auto-suggest': { + productCodes: ['aem-sites'], + }, + }), + isHandlerEnabledForSite: (handler) => handler !== 'summit-plg', + }), + }; + context.dataAccess.Opportunity = { allBySiteIdAndStatus: sandbox.stub(), create: sandbox.stub(), @@ -420,15 +431,15 @@ describe('collectCWVDataAndImportCode Tests', () => { expect(GoogleClient.createFrom).to.have.been.calledWith(stepContext, auditUrl); expect(context.dataAccess.Opportunity.create).to.have.been.calledOnceWith(expectedOppty); - // make sure that newly oppty has all 4 new suggestions + // make sure that newly oppty has 2 new suggestions (only failing-metric pages) expect(oppty.addSuggestions).to.have.been.calledOnce; const suggestionsArg = oppty.addSuggestions.getCall(0).args[0]; - expect(suggestionsArg).to.be.an('array').with.lengthOf(4); + expect(suggestionsArg).to.be.an('array').with.lengthOf(2); // CWV suggestions include jiraLink (empty until user saves URL in UI) suggestionsArg.forEach((s) => expect(s.data).to.have.property('jiraLink', '')); }); - it('handles audit result with only group entries for maxOrganicForUrls coverage', async () => { + it('handles audit result with only group entries for maxConfidenceForUrls coverage', async () => { context.dataAccess.Opportunity.allBySiteIdAndStatus.resolves([]); context.dataAccess.Opportunity.create.resolves(oppty); sinon.stub(GoogleClient, 'createFrom').resolves({}); @@ -441,7 +452,12 @@ describe('collectCWVDataAndImportCode Tests', () => { name: 'Some pages', pageviews: 5000, organic: 3000, - metrics: [], + metrics: [{ + deviceType: 'mobile', + lcp: 3000, // > 2500 threshold — ensures group passes hasFailingMetrics + cls: null, + inp: null, + }], }, ], auditContext: { interval: 7 }, @@ -515,10 +531,10 @@ describe('collectCWVDataAndImportCode Tests', () => { expect(existingSuggestions[1].setData.firstCall.args[0]).to.deep.equal(suggestions[1].data); expect(context.dataAccess.Suggestion.saveMany).to.have.been.calledOnce; - // make sure that 3 new suggestions are created + // make sure that 1 new suggestion is created (/docs/ — the only new failing-metric page) expect(oppty.addSuggestions).to.have.been.calledOnce; const suggestionsArg = oppty.addSuggestions.getCall(0).args[0]; - expect(suggestionsArg).to.be.an('array').with.lengthOf(3); + expect(suggestionsArg).to.be.an('array').with.lengthOf(1); }); it('creates a new opportunity object when GSC connection returns null', async () => { @@ -537,7 +553,7 @@ describe('collectCWVDataAndImportCode Tests', () => { expect(oppty.addSuggestions).to.have.been.calledOnce; const suggestionsArg = oppty.addSuggestions.getCall(0).args[0]; - expect(suggestionsArg).to.be.an('array').with.lengthOf(4); + expect(suggestionsArg).to.be.an('array').with.lengthOf(2); }); it('creates a new opportunity object without GSC if not connected', async () => { @@ -555,7 +571,7 @@ describe('collectCWVDataAndImportCode Tests', () => { expect(oppty.addSuggestions).to.have.been.calledOnce; const suggestionsArg = oppty.addSuggestions.getCall(0).args[0]; - expect(suggestionsArg).to.be.an('array').with.lengthOf(4); + expect(suggestionsArg).to.be.an('array').with.lengthOf(2); }); it('calls processAutoSuggest when suggestions have no guidance', async () => { @@ -610,6 +626,60 @@ describe('collectCWVDataAndImportCode Tests', () => { expect(context.sqs.sendMessage).to.not.have.been.called; }); + it('filters CWV suggestions to only failing-metric pages for all sites', async () => { + context.dataAccess.Opportunity.allBySiteIdAndStatus.resolves([]); + context.dataAccess.Opportunity.create.resolves(oppty); + sinon.stub(GoogleClient, 'createFrom').resolves({}); + + const stepContext = { ...context, site, audit: mockAudit, finalUrl: auditUrl }; + await syncOpportunityAndSuggestionsStep(stepContext); + + // Of the 4 CWV entries (pageviews >= 7000): + // - Group: mobile cls=0.27 > 0.1 → FAILS + // - /developer/block-collection: → PASSES (all below threshold) + // - /docs/: mobile lcp=26276 > 2500 → FAILS + // - /tools/rum/explorer.html: → PASSES (all below threshold) + // Global filter should yield 2 suggestions + expect(oppty.addSuggestions).to.have.been.calledOnce; + const suggestionsArg = oppty.addSuggestions.getCall(0).args[0]; + expect(suggestionsArg).to.be.an('array').with.lengthOf(2); + expect(context.log.info).to.have.been.calledWith( + sinon.match(/2 of 4 CWV entries have failing metrics/), + ); + }); + + it('stores no suggestions when all pages have passing metrics', async () => { + const allPassingCwvData = [ + { + type: 'url', + url: 'https://www.aem.live/docs/', + pageviews: 9000, + organic: 500, + metrics: [{ + deviceType: 'desktop', + pageviews: 9000, + organic: 500, + lcp: 1200, + cls: 0.05, + inp: 150, + }], + }, + ]; + + context.dataAccess.Opportunity.allBySiteIdAndStatus.resolves([]); + context.dataAccess.Opportunity.create.resolves(oppty); + sinon.stub(GoogleClient, 'createFrom').resolves({}); + + const allPassingAudit = { + ...mockAudit, + getAuditResult: () => ({ cwv: allPassingCwvData, auditContext: { interval: 7 } }), + }; + const stepContext = { ...context, site, audit: allPassingAudit, finalUrl: auditUrl }; + await syncOpportunityAndSuggestionsStep(stepContext); + + expect(oppty.addSuggestions).to.not.have.been.called; + }); + it('calls processAutoSuggest when some suggestions have guidance and some do not', async () => { // Mock mixed suggestions - some with guidance, some without const mockSuggestions = [ diff --git a/test/audits/cwv/kpi-metrics.test.js b/test/audits/cwv/kpi-metrics.test.js index 6288cc3d49..776acf5aed 100644 --- a/test/audits/cwv/kpi-metrics.test.js +++ b/test/audits/cwv/kpi-metrics.test.js @@ -16,7 +16,7 @@ import { expect, use } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import chaiAsPromised from 'chai-as-promised'; -import calculateKpiDeltasForAudit from '../../../src/cwv/kpi-metrics.js'; +import calculateKpiDeltasForAudit, { calculateConfidenceScore } from '../../../src/cwv/kpi-metrics.js'; use(sinonChai); use(chaiAsPromised); @@ -219,6 +219,42 @@ describe('calculates KPI deltas correctly', () => { expect(result).to.deep.equal(expectedAggregatedKpi); }); + it('calculates confidence score as sum of projected traffic lost across devices', () => { + const entry = { + metrics: [ + { + deviceType: 'desktop', + organic: 2000, + lcp: 2000, // green + cls: 0.2, // poor + inp: 220, // poor — Needs Improvement → 0.05 + }, + { + deviceType: 'mobile', + organic: 900, + lcp: 2700, // poor + cls: 0.2, // poor + inp: 220, // poor — Poor → 0.1 + }, + ], + }; + // desktop: 2000 * 0.05 = 100, mobile: 900 * 0.1 = 90 → total 190 + expect(calculateConfidenceScore(entry)).to.equal(190); + }); + + it('calculates confidence score as zero when no organic traffic', () => { + const entry = { + metrics: [ + { deviceType: 'desktop', lcp: 3000, cls: 0.2, inp: 220 }, + ], + }; + expect(calculateConfidenceScore(entry)).to.equal(0); + }); + + it('calculates confidence score as zero for empty metrics array', () => { + expect(calculateConfidenceScore({ metrics: [] })).to.equal(0); + }); + it('entries without organic', async () => { const auditData = { auditResult: {