Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 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
28 changes: 24 additions & 4 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 } 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 @@ -50,12 +69,13 @@ 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),
0,
...cwvData.filter((entry) => entry.type === 'url').map((entry) => entry.pageviews),
);

await syncSuggestions({
opportunity,
newData: auditResult.cwv,
newData: cwvData,
context,
buildKey,
bypassValidationForPlg: true,
Expand Down
86 changes: 78 additions & 8 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,10 +431,10 @@ 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', ''));
});
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
Loading