Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions src/cwv/kpi-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
42 changes: 32 additions & 10 deletions src/cwv/opportunity-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand All @@ -49,25 +68,28 @@ 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,
mapNewSuggestion: (entry) => ({
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: '',
Expand Down
88 changes: 79 additions & 9 deletions test/audits/cwv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('collectCWVDataAndImportCode Tests', () => {
productCodes: ['aem-sites'],
},
}),
isHandlerEnabledForSite: () => true,
isHandlerEnabledForSite: (handler) => handler !== 'summit-plg',
}),
},
},
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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({});
Expand All @@ -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 },
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 = [
Expand Down
38 changes: 37 additions & 1 deletion test/audits/cwv/kpi-metrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down
Loading