diff --git a/src/support/grant-suggestions-handler.js b/src/support/grant-suggestions-handler.js index 91ce5b388..4d6456b2c 100644 --- a/src/support/grant-suggestions-handler.js +++ b/src/support/grant-suggestions-handler.js @@ -98,6 +98,33 @@ const OPPORTUNITY_STRATEGIES = { ); }, }, + // For PLG customers, CWV grants are limited to the top 3 pages by page views + // (pages with the most traffic that have CWV issues). Among those 3 pages, + // suggestions are sorted by confidence score (rank) descending so the most + // impactful page is granted first. Confidence score is set by the audit worker + // as projected traffic lost (organic × metric-severity multiplier). + // Tie-breaks by suggestion ID ascending for deterministic ordering. + cwv: { + groupFn: (suggestions) => { + const getPageviews = (s) => { + const data = typeof s?.getData === 'function' ? s.getData() : s?.data; + return data?.pageviews ?? 0; + }; + return [...suggestions] + .sort((a, b) => getPageviews(b) - getPageviews(a)) + .slice(0, 3) + .map((s) => createGroup([s])); + }, + sortFn: (groupA, groupB) => { + const rankDiff = groupB.getRank() - groupA.getRank(); + if (rankDiff !== 0) return rankDiff; + const a = groupA.items[0]; + const b = groupB.items[0]; + const idA = typeof a?.getId === 'function' ? a.getId() : (a?.id ?? ''); + const idB = typeof b?.getId === 'function' ? b.getId() : (b?.id ?? ''); + return idA.localeCompare(idB); + }, + }, }; /** diff --git a/test/support/grant-suggestions-handler.test.js b/test/support/grant-suggestions-handler.test.js index fa3566082..4fe884f01 100644 --- a/test/support/grant-suggestions-handler.test.js +++ b/test/support/grant-suggestions-handler.test.js @@ -200,6 +200,106 @@ describe('grant-suggestions-handler', () => { const groups = getTopSuggestions([s1, s2]); expect(groups).to.have.lengthOf(2); }); + + it('cwv: limits to top 3 pages by pageviews before sorting by confidence', () => { + const mk = (id, pageviews, rank) => ({ + getId: () => id, getRank: () => rank, getData: () => ({ pageviews }), + }); + // 5 pages — top 3 by pageviews are s1(5000), s3(4000), s4(3000) + const s1 = mk('id-1', 5000, 200); + const s2 = mk('id-2', 1000, 9000); // high confidence but low pageviews → excluded + const s3 = mk('id-3', 4000, 500); + const s4 = mk('id-4', 3000, 100); + const s5 = mk('id-5', 2000, 300); // excluded (4th by pageviews) + const groups = getTopSuggestions([s1, s2, s3, s4, s5], 'cwv'); + // only top 3 by pageviews included + expect(groups).to.have.lengthOf(3); + const ids = groups.flatMap((g) => g.items.map((s) => s.getId())); + expect(ids).to.include.members(['id-1', 'id-3', 'id-4']); + expect(ids).to.not.include('id-2'); + expect(ids).to.not.include('id-5'); + // sorted by confidence descending: s3(500) > s1(200) > s4(100) + expect(groups[0].items[0]).to.equal(s3); + expect(groups[1].items[0]).to.equal(s1); + expect(groups[2].items[0]).to.equal(s4); + }); + + it('cwv: returns fewer than 3 when fewer pages available', () => { + const s1 = { getId: () => 'id-1', getRank: () => 500, getData: () => ({ pageviews: 3000 }) }; + const s2 = { getId: () => 'id-2', getRank: () => 9000, getData: () => ({ pageviews: 1000 }) }; + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups).to.have.lengthOf(2); + }); + + it('cwv: sorts top 3 pages by confidence score descending', () => { + const mk = (id, pageviews, rank) => ({ + getId: () => id, getRank: () => rank, getData: () => ({ pageviews }), + }); + const s1 = mk('id-1', 5000, 500); + const s2 = mk('id-2', 4000, 9000); + const s3 = mk('id-3', 3000, 200); + const groups = getTopSuggestions([s1, s2, s3], 'cwv'); + expect(groups).to.have.lengthOf(3); + expect(groups[0].items[0]).to.equal(s2); // 9000 first + expect(groups[1].items[0]).to.equal(s1); // 500 second + expect(groups[2].items[0]).to.equal(s3); // 200 last + }); + + it('cwv: sorts top 3 pages by confidence score descending using plain objects', () => { + const s1 = { id: 'id-1', rank: 500, data: { pageviews: 5000 } }; + const s2 = { id: 'id-2', rank: 9000, data: { pageviews: 4000 } }; + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups).to.have.lengthOf(2); + expect(groups[0].items[0]).to.equal(s2); // 9000 first + expect(groups[1].items[0]).to.equal(s1); // 500 second + }); + + it('cwv: sorts zero confidence score to the end among top 3 pages', () => { + const mk = (id, pageviews, rank) => ({ + getId: () => id, getRank: () => rank, getData: () => ({ pageviews }), + }); + const s1 = mk('id-1', 5000, 1000); + const s2 = mk('id-2', 4000, 0); + const groups = getTopSuggestions([s2, s1], 'cwv'); + expect(groups[0].items[0]).to.equal(s1); // 1000 first + expect(groups[1].items[0]).to.equal(s2); // 0 last + }); + + it('cwv: breaks confidence score ties by id ascending', () => { + const mk = (id, pageviews, rank) => ({ + getId: () => id, getRank: () => rank, getData: () => ({ pageviews }), + }); + const s1 = mk('id-b', 5000, 500); + const s2 = mk('id-a', 4000, 500); + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups[0].items[0]).to.equal(s2); // id-a before id-b + expect(groups[1].items[0]).to.equal(s1); + }); + + it('cwv: breaks confidence score ties by id ascending using plain object id', () => { + const s1 = { id: 'id-b', rank: 500, data: { pageviews: 5000 } }; + const s2 = { id: 'id-a', rank: 500, data: { pageviews: 4000 } }; + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups[0].items[0]).to.equal(s2); // id-a before id-b + expect(groups[1].items[0]).to.equal(s1); + }); + + it('cwv: breaks confidence score ties stably when items have no id', () => { + const s1 = { rank: 500, data: { pageviews: 5000 } }; + const s2 = { rank: 500, data: { pageviews: 4000 } }; + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups).to.have.lengthOf(2); // both empty-string ids → stable, no throw + }); + + it('cwv: treats missing pageviews as zero for pageviews sort', () => { + const s1 = { getId: () => 'id-1', getRank: () => 500, getData: () => ({}) }; + const s2 = { getId: () => 'id-2', getRank: () => 200, getData: () => ({ pageviews: 1000 }) }; + const groups = getTopSuggestions([s1, s2], 'cwv'); + expect(groups).to.have.lengthOf(2); + // s2 has higher pageviews so it's included; s1 pageviews=0 still included (only 2 total) + const ids = groups.flatMap((g) => g.items.map((s) => s.getId())); + expect(ids).to.include.members(['id-1', 'id-2']); + }); }); describe('grantSuggestionsForOpportunity', () => { @@ -353,7 +453,7 @@ describe('grant-suggestions-handler', () => { // Only 1 remaining, so only 1 grant call expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce; expect(SuggestionGrant.grantSuggestions.firstCall.args[0]) - .to.deep.equal(['sugg-1']); + .to.deep.equal(['sugg-2']); }); describe('new token (new cycle) — revoke and re-grant', () => {