Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 33 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,27 @@ 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);

// Detect PLG sites (summit-plg handler enabled) to apply filtered suggestions.
// PLG sites only receive suggestions for pages where at least one CWV metric is failing,
// sorted by page views descending (already ordered from step 1).
const { Configuration } = context.dataAccess;
const configuration = await Configuration.findLatest();
const isSummitPlgSite = configuration.isHandlerEnabledForSite('summit-plg', site);

const cwvData = isSummitPlgSite
? auditResult.cwv.filter(hasFailingMetrics)
: auditResult.cwv;

if (isSummitPlgSite) {
log.info(`[syncOpportunitiesAndSuggestions] PLG 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 +78,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
93 changes: 92 additions & 1 deletion 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 @@ -610,6 +621,86 @@ describe('collectCWVDataAndImportCode Tests', () => {
expect(context.sqs.sendMessage).to.not.have.been.called;
});

it('filters CWV suggestions to only failing-metric pages for PLG sites', async () => {
context.dataAccess.Configuration = {
findLatest: sandbox.stub().resolves({
getHandlers: () => ({ 'cwv-auto-suggest': { productCodes: ['aem-sites'] } }),
isHandlerEnabledForSite: (handler) => handler === 'summit-plg',
}),
};
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)
// PLG 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(/PLG site.*2 of 4 CWV entries have failing metrics/),
);
});

it('stores no suggestions for PLG sites 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.Configuration = {
findLatest: sandbox.stub().resolves({
getHandlers: () => ({ 'cwv-auto-suggest': { productCodes: ['aem-sites'] } }),
isHandlerEnabledForSite: (handler) => handler === 'summit-plg',
}),
};
context.dataAccess.Opportunity.allBySiteIdAndStatus.resolves([]);
context.dataAccess.Opportunity.create.resolves(oppty);
sinon.stub(GoogleClient, 'createFrom').resolves({});

const plgAudit = {
...mockAudit,
getAuditResult: () => ({ cwv: allPassingCwvData, auditContext: { interval: 7 } }),
};
const stepContext = { ...context, site, audit: plgAudit, finalUrl: auditUrl };
await syncOpportunityAndSuggestionsStep(stepContext);

expect(oppty.addSuggestions).to.not.have.been.called;
});

it('stores all pages for non-PLG sites regardless of metric pass/fail', 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);

// Non-PLG: all 4 entries stored, same as before
expect(oppty.addSuggestions).to.have.been.calledOnce;
const suggestionsArg = oppty.addSuggestions.getCall(0).args[0];
expect(suggestionsArg).to.be.an('array').with.lengthOf(4);
});

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