Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# SpaceCat Audit Worker


> SpaceCat Audit Worker for auditing edge delivery sites.

## Status
Expand Down
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