diff --git a/docs/specs/cwv-trends-audit/spec.md b/docs/specs/cwv-trends-audit/spec.md new file mode 100644 index 0000000000..581c68ca15 --- /dev/null +++ b/docs/specs/cwv-trends-audit/spec.md @@ -0,0 +1,258 @@ +# CWV Trends Audit — Specification + +## Overview + +A weekly audit (`cwv-trends-audit`) that reads pre-imported CWV and engagement data from S3, classifies URLs as Good / Needs Improvement / Poor over a 28-day rolling window, and creates device-specific **Web Performance Trends Report** opportunities in SpaceCat. + +The audit runs once per site. The device type (mobile or desktop) is determined from the site's handler configuration, defaulting to `mobile`. + +### Acceptance Criteria + +- [ ] Scheduled as `every-sunday` +- [ ] Reads 28 days of pre-imported CWV data from S3 +- [ ] CWV metrics categorized using standard thresholds (LCP ≤ 2500/4000, CLS ≤ 0.1/0.25, INP ≤ 200/500) +- [ ] Device type read from `site.getConfig().getHandlers()['cwv-trends-audit'].deviceType` +- [ ] Creates/updates "Mobile Web Performance Trends Report" or "Desktop Web Performance Trends Report" opportunity by title match +- [ ] Audit result payload matches schema: `metadata`, `trendData`, `summary`, `urlDetails` +- [ ] URLs filtered by minimum 1000 pageviews, sorted descending +- [ ] 100% unit test coverage + +--- + +## Architecture + +### Impacted Repositories + +| Repository | Changes | +|---|---| +| `spacecat-shared` | Add `CWV_TRENDS_AUDIT` to `Audit.AUDIT_TYPES` | +| `spacecat-audit-worker` | New `src/cwv-trends-audit/` module with runner, opportunity handler, tests | +| `spacecat-api-service` | Register audit type (dependency bump) | + +### Data Flow + +``` +S3 Bucket (pre-imported by cwv-trends-daily import) + │ + ▼ +cwv-trends-audit runner + ├─ Read 28 days from: metrics/cwv-trends/cwv-trends-daily-{date}.json + ├─ Filter URLs by device type + MIN_PAGEVIEWS (1000) + ├─ Categorize URLs (Good/NI/Poor) per day → trendData + ├─ Build summary (current week avg vs previous week avg) + ├─ Build urlDetails (sorted by pageviews, sequential id, change values) + │ + └─ Post-processor: opportunityHandler + ├─ Find existing opportunity by title (comparisonFn) + ├─ Create opportunity if not found, update if found + └─ syncSuggestions (one per URL, keyed by url) +``` + +--- + +## S3 Data Source + +- **Bucket:** `S3_IMPORTER_BUCKET_NAME` (from environment) +- **Key pattern:** `metrics/cwv-trends/cwv-trends-daily-{YYYY-MM-DD}.json` +- **Content:** JSON array of URL entries, each with a `metrics` array containing device-specific CWV data +- The audit reads from S3 only — no direct RUM API calls + +### S3 Payload Structure (per date file) + +```json +[ + { + "url": "https://www.example.com/page", + "metrics": [ + { + "deviceType": "mobile", + "pageviews": 5000, + "lcp": 2000, + "cls": 0.08, + "inp": 180, + "bounceRate": 0.25, + "engagement": 0.75, + "clickRate": 0.60 + } + ] + } +] +``` + +--- + +## Data Processing + +### URL Filtering + +- Filter by configured device type (match `metrics[].deviceType`) +- Minimum `MIN_PAGEVIEWS = 1000` pageviews +- Skip URLs with `deviceType === 'undefined'` (log warning) +- Sort by pageviews descending + +### CWV Categorization Thresholds + +| Metric | Good | Poor | +|--------|------|------| +| LCP | ≤ 2500ms | > 4000ms | +| CLS | ≤ 0.1 | > 0.25 | +| INP | ≤ 200ms | > 500ms | + +- **Good:** All available metrics within good thresholds +- **Poor:** Any metric exceeds poor threshold (OR logic) +- **Needs Improvement:** Everything else +- **Null metrics:** Skip in categorization (use available metrics only) + +### Summary Calculation + +- **Current week:** Last 7 days of the 28-day window +- **Previous week:** Days 15–21 of the 28-day window +- Each stat: `{ current, previous, change, percentageChange, status }` +- Change = current - previous +- Percentage change = ((current - previous) / previous) × 100 + +### URL Details + +- Sequential `id` (string "1", "2", ...) sorted by pageviews descending +- Percentage fields (`bounceRate`, `engagement`, `clickRate`) multiplied by 100 +- Raw fields (`pageviews`, `lcp`, `cls`, `inp`) kept as-is +- Change values: current week average - previous week average + +--- + +## Site Configuration + +```json +{ + "handlers": { + "cwv-trends-audit": { + "deviceType": "mobile" + } + } +} +``` + +- Device type read from: `site.getConfig().getHandlers()['cwv-trends-audit'].deviceType` +- Defaults to `'mobile'` if not configured or if config is absent + +--- + +## Opportunities & Suggestions + +### Opportunity Matching + +The opportunity handler uses `convertToOpportunity` with a `comparisonFn` that matches by **title**: + +- If an existing opportunity with the matching title is found (status `NEW`), it is updated +- If no match, a new opportunity is created +- Titles: "Mobile Web Performance Trends Report" / "Desktop Web Performance Trends Report" + +### Opportunity Data + +```javascript +{ + runbook: '', + origin: 'AUTOMATION', + title: OPPORTUNITY_TITLES[deviceType], + description: 'Web Performance Trends Report tracking CWV metrics over time.', + guidance: { steps: [...] }, + tags: ['Web Performance', 'CWV'], + data: { deviceType, dataSources: ['rum', 'site'] } +} +``` + +### Suggestions + +One suggestion per URL in `urlDetails`, synced via `syncSuggestions`: + +```javascript +{ + opportunityId: opportunity.getId(), + type: 'CONTENT_UPDATE', + rank: entry.pageviews, + data: { ...entry } +} +``` + +> **Note:** Suggestion type `CONTENT_UPDATE` matches the ESO API pattern in `experience-system-outages/src/services/spaceCatForwarder.cjs`. ESO doesn't set tags; we add `['Web Performance', 'CWV']` for UI filtering. + +--- + +## Audit Result Schema + +```json +{ + "auditResult": { + "metadata": { + "domain": "www.example.com", + "deviceType": "mobile", + "startDate": "2025-11-01", + "endDate": "2025-11-28" + }, + "trendData": [ + { "date": "2025-11-01", "good": 2, "needsImprovement": 1, "poor": 3 } + ], + "summary": { + "good": { "current": 4, "previous": 2, "change": 2, "percentageChange": 100, "status": "good" }, + "needsImprovement": { "current": 2, "previous": 1, "change": 1, "percentageChange": 100, "status": "needsImprovement" }, + "poor": { "current": 1, "previous": 3, "change": -2, "percentageChange": -66.67, "status": "poor" }, + "totalUrls": 7 + }, + "urlDetails": [ + { + "id": "1", + "url": "https://www.example.com/products", + "status": "needsImprovement", + "pageviews": 4400, + "pageviewsChange": 1200, + "lcp": 2756, + "lcpChange": 856, + "cls": 0.011, + "clsChange": -0.005, + "inp": 56, + "inpChange": -24, + "bounceRate": 45.5, + "bounceRateChange": -5.2, + "engagement": 65.3, + "engagementChange": 8.1, + "clickRate": 28.7, + "clickRateChange": 3.4 + } + ] + }, + "fullAuditRef": "metrics/cwv-trends/" +} +``` + +--- + +## File Structure + +``` +src/cwv-trends-audit/ +├── constants.js # AUDIT_TYPE, thresholds, titles, config defaults +├── cwv-categorizer.js # categorizeUrl(lcp, cls, inp) → good|NI|poor|null +├── data-reader.js # readTrendData(), formatDate(), subtractDays() +├── handler.js # AuditBuilder entry point (runner + post-processor) +├── opportunity-data-mapper.js # createOpportunityData({ deviceType }) +├── opportunity-handler.js # Post-processor: convertToOpportunity + syncSuggestions +└── utils.js # Main runner logic (cwvTrendsRunner) + +test/audits/cwv-trends-audit/ +├── constants.test.js +├── cwv-categorizer.test.js +├── data-reader.test.js +├── handler.test.js +├── opportunity-data-mapper.test.js +├── opportunity-handler.test.js +└── utils.test.js +``` + +--- + +## Deployment Checklist + +1. Merge and publish `spacecat-shared` (adds `CWV_TRENDS_AUDIT` type) +2. Bump `@adobe/spacecat-shared-data-access` in `spacecat-audit-worker` and `spacecat-api-service` +3. Register audit: `registerAudit('cwv-trends-audit', false, 'every-sunday', [productCodes])` +4. Enable per-site via configuration diff --git a/src/cwv-trends-audit/constants.js b/src/cwv-trends-audit/constants.js new file mode 100644 index 0000000000..d61b585669 --- /dev/null +++ b/src/cwv-trends-audit/constants.js @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const AUDIT_TYPE = 'cwv-trends-audit'; +export const TREND_DAYS = 28; +export const CURRENT_WEEK_DAYS = 7; +export const S3_BASE_PATH = 'metrics/cwv-trends'; +export const MIN_PAGEVIEWS = 1000; +export const DEFAULT_DEVICE_TYPE = 'mobile'; + +// Core Web Vitals thresholds based on https://web.dev/articles/vitals +export const CWV_THRESHOLDS = { + LCP: { GOOD: 2500, POOR: 4000 }, + CLS: { GOOD: 0.1, POOR: 0.25 }, + INP: { GOOD: 200, POOR: 500 }, +}; + +export const OPPORTUNITY_TITLES = { + mobile: 'Mobile Web Performance Trends Report', + desktop: 'Desktop Web Performance Trends Report', +}; diff --git a/src/cwv-trends-audit/cwv-categorizer.js b/src/cwv-trends-audit/cwv-categorizer.js new file mode 100644 index 0000000000..7235c03c41 --- /dev/null +++ b/src/cwv-trends-audit/cwv-categorizer.js @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CWV_THRESHOLDS } from './constants.js'; + +/** + * Categorizes a URL based on CWV metrics. + * - Good: all available metrics within good thresholds + * - Poor: any available metric exceeds poor threshold (OR logic) + * - Needs Improvement: everything else + * - null: no metrics available + * + * @param {number|null} lcp - Largest Contentful Paint (ms) + * @param {number|null} cls - Cumulative Layout Shift + * @param {number|null} inp - Interaction to Next Paint (ms) + * @returns {'good'|'needsImprovement'|'poor'|null} + */ +export function categorizeUrl(lcp, cls, inp) { + const hasLcp = lcp !== null && lcp !== undefined; + const hasCls = cls !== null && cls !== undefined; + const hasInp = inp !== null && inp !== undefined; + + if (!hasLcp && !hasCls && !hasInp) return null; + + const isPoor = (hasLcp && lcp > CWV_THRESHOLDS.LCP.POOR) + || (hasCls && cls > CWV_THRESHOLDS.CLS.POOR) + || (hasInp && inp > CWV_THRESHOLDS.INP.POOR); + if (isPoor) return 'poor'; + + const isGood = (!hasLcp || lcp <= CWV_THRESHOLDS.LCP.GOOD) + && (!hasCls || cls <= CWV_THRESHOLDS.CLS.GOOD) + && (!hasInp || inp <= CWV_THRESHOLDS.INP.GOOD); + if (isGood) return 'good'; + + return 'needsImprovement'; +} diff --git a/src/cwv-trends-audit/data-reader.js b/src/cwv-trends-audit/data-reader.js new file mode 100644 index 0000000000..6a58e939ab --- /dev/null +++ b/src/cwv-trends-audit/data-reader.js @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { getObjectFromKey } from '../utils/s3-utils.js'; +import { S3_BASE_PATH } from './constants.js'; + +export function formatDate(date) { + return date.toISOString().split('T')[0]; +} + +export function subtractDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() - days); + return result; +} + +function buildS3Key(dateStr) { + return `${S3_BASE_PATH}/cwv-trends-daily-${dateStr}.json`; +} + +/** + * Reads CWV trend data from S3 for a given number of days ending on endDate. + * Fetches all dates in parallel and skips missing ones gracefully. + * + * @param {object} s3Client - AWS S3 client + * @param {string} bucketName - S3 bucket name + * @param {Date} endDate - End date (inclusive) + * @param {number} days - Number of days to read + * @param {object} log - Logger instance + * @returns {Promise>} Daily data sorted chronologically + */ +export async function readTrendData(s3Client, bucketName, endDate, days, log) { + const promises = []; + + for (let i = days - 1; i >= 0; i -= 1) { + const date = subtractDays(endDate, i); + const dateStr = formatDate(date); + const key = buildS3Key(dateStr); + + promises.push( + getObjectFromKey(s3Client, bucketName, key, log) + .then((raw) => { + let parsed = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + } + if (parsed && Array.isArray(parsed)) { + return { date: dateStr, data: parsed }; + } + log.warn(`Empty or invalid S3 data for date ${dateStr}`); + return null; + }) + .catch(() => { + log.warn(`Missing S3 data for date ${dateStr}, skipping`); + return null; + }), + ); + } + + const results = await Promise.all(promises); + return results.filter(Boolean); +} diff --git a/src/cwv-trends-audit/handler.js b/src/cwv-trends-audit/handler.js new file mode 100644 index 0000000000..83bc1297d0 --- /dev/null +++ b/src/cwv-trends-audit/handler.js @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { AuditBuilder } from '../common/audit-builder.js'; +import { noopUrlResolver } from '../common/index.js'; +import cwvTrendsRunner from './utils.js'; +import opportunityHandler from './opportunity-handler.js'; + +export default new AuditBuilder() + .withRunner(cwvTrendsRunner) + .withUrlResolver(noopUrlResolver) + .withPostProcessors([opportunityHandler]) + .build(); diff --git a/src/cwv-trends-audit/opportunity-data-mapper.js b/src/cwv-trends-audit/opportunity-data-mapper.js new file mode 100644 index 0000000000..69128a3d13 --- /dev/null +++ b/src/cwv-trends-audit/opportunity-data-mapper.js @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { DATA_SOURCES } from '../common/constants.js'; +import { OPPORTUNITY_TITLES } from './constants.js'; + +/** + * Creates the opportunity data object for a CWV Trends audit. + * The title is determined by the deviceType in the audit result. + * + * @param {object} props - Props containing auditResult from the audit run + * @returns {object} Opportunity data + */ +export function createOpportunityData(props) { + const { deviceType } = props; + + return { + runbook: '', + origin: 'AUTOMATION', + title: OPPORTUNITY_TITLES[deviceType] || OPPORTUNITY_TITLES.mobile, + description: 'Web Performance Trends Report tracking CWV metrics over time.', + guidance: { + steps: [ + 'Review CWV trends to identify performance degradation patterns.', + 'Investigate URLs with Poor CWV scores for LCP, CLS, and INP issues.', + 'Prioritize fixes for high-traffic pages with declining metrics.', + 'Monitor trends after optimization to verify improvements.', + ], + }, + tags: ['Web Performance', 'CWV'], + data: { + deviceType, + dataSources: [DATA_SOURCES.RUM, DATA_SOURCES.SITE], + }, + }; +} diff --git a/src/cwv-trends-audit/opportunity-handler.js b/src/cwv-trends-audit/opportunity-handler.js new file mode 100644 index 0000000000..c5af7e7e97 --- /dev/null +++ b/src/cwv-trends-audit/opportunity-handler.js @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { syncSuggestions } from '../utils/data-access.js'; +import { convertToOpportunity } from '../common/opportunity.js'; +import { createOpportunityData } from './opportunity-data-mapper.js'; +import { AUDIT_TYPE, OPPORTUNITY_TITLES } from './constants.js'; + +/** + * Post-processor that creates/updates the opportunity and syncs suggestions. + * Matches existing opportunities by title so mobile and desktop remain separate. + */ +export default async function opportunityHandler(finalUrl, auditData, context) { + const { auditResult } = auditData; + const { deviceType } = auditResult.metadata; + + const expectedTitle = OPPORTUNITY_TITLES[deviceType] || OPPORTUNITY_TITLES.mobile; + const comparisonFn = (oppty) => oppty.getTitle() === expectedTitle; + + const opportunity = await convertToOpportunity( + finalUrl, + auditData, + context, + createOpportunityData, + AUDIT_TYPE, + { deviceType }, + comparisonFn, + ); + + await syncSuggestions({ + opportunity, + newData: auditResult.urlDetails, + context, + buildKey: (data) => data.url, + mapNewSuggestion: (entry) => ({ + opportunityId: opportunity.getId(), + type: 'CONTENT_UPDATE', + rank: entry.pageviews, + data: { ...entry }, + }), + }); + + return auditData; +} diff --git a/src/cwv-trends-audit/utils.js b/src/cwv-trends-audit/utils.js new file mode 100644 index 0000000000..c3f0d5e1b3 --- /dev/null +++ b/src/cwv-trends-audit/utils.js @@ -0,0 +1,281 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + MIN_PAGEVIEWS, TREND_DAYS, CURRENT_WEEK_DAYS, S3_BASE_PATH, + DEFAULT_DEVICE_TYPE, AUDIT_TYPE, +} from './constants.js'; +import { readTrendData, formatDate, subtractDays } from './data-reader.js'; +import { categorizeUrl } from './cwv-categorizer.js'; + +/** + * Filters URLs by device type and minimum pageviews. + */ +function filterUrls(urlEntries, deviceType, log) { + return urlEntries + .map((entry) => { + const metrics = entry.metrics?.find((m) => m.deviceType === deviceType); + if (!metrics) return null; + if (metrics.pageviews < MIN_PAGEVIEWS) return null; + + if (deviceType === 'undefined') { + log.warn(`Skipping URL with undefined device type: ${entry.url}`); + return null; + } + + return { + url: entry.url, + pageviews: metrics.pageviews, + bounceRate: metrics.bounceRate, + engagement: metrics.engagement, + clickRate: metrics.clickRate, + lcp: metrics.lcp, + cls: metrics.cls, + inp: metrics.inp, + }; + }) + .filter(Boolean) + .sort((a, b) => b.pageviews - a.pageviews); +} + +/** + * Builds trend data: per-day counts of good/needsImprovement/poor URLs. + */ +function buildTrendData(dailyData, deviceType, log) { + return dailyData.map((day) => { + const urls = filterUrls(day.data, deviceType, log); + let good = 0; + let needsImprovement = 0; + let poor = 0; + + for (const url of urls) { + const category = categorizeUrl(url.lcp, url.cls, url.inp); + if (category === 'good') good += 1; + else if (category === 'needsImprovement') needsImprovement += 1; + else if (category === 'poor') poor += 1; + } + + return { + date: day.date, good, needsImprovement, poor, + }; + }); +} + +/** + * Calculates average counts over a slice of trendData entries. + */ +function averageCounts(entries) { + if (entries.length === 0) return { good: 0, needsImprovement: 0, poor: 0 }; + + const sum = entries.reduce( + (acc, e) => ({ + good: acc.good + e.good, + needsImprovement: acc.needsImprovement + e.needsImprovement, + poor: acc.poor + e.poor, + }), + { good: 0, needsImprovement: 0, poor: 0 }, + ); + + return { + good: sum.good / entries.length, + needsImprovement: sum.needsImprovement / entries.length, + poor: sum.poor / entries.length, + }; +} + +function pctChange(current, previous) { + if (previous === 0) return current === 0 ? 0 : 100; + return Math.round(((current - previous) / previous) * 10000) / 100; +} + +/** + * Builds summary comparing current week avg (last 7 days) vs previous week avg (days 15-21). + */ +function buildSummary(trendData, totalUrls) { + const len = trendData.length; + const currentWeek = trendData.slice(Math.max(0, len - CURRENT_WEEK_DAYS)); + const previousWeek = trendData.slice( + Math.max(0, len - 2 * CURRENT_WEEK_DAYS), + Math.max(0, len - CURRENT_WEEK_DAYS), + ); + + const curr = averageCounts(currentWeek); + const prev = averageCounts(previousWeek); + + const makeStat = (category) => { + const current = Math.round(curr[category] * 100) / 100; + const previous = Math.round(prev[category] * 100) / 100; + const change = Math.round((current - previous) * 100) / 100; + return { + current, + previous, + change, + percentageChange: pctChange(current, previous), + status: category, + }; + }; + + return { + good: makeStat('good'), + needsImprovement: makeStat('needsImprovement'), + poor: makeStat('poor'), + totalUrls, + }; +} + +/** + * Computes weekly average for a specific URL and field. + */ +function weeklyAvgForUrl(urlKey, dailySlice, deviceType, field, log) { + let sum = 0; + let count = 0; + + for (const day of dailySlice) { + const urls = filterUrls(day.data, deviceType, log); + const match = urls.find((u) => u.url === urlKey); + if (match && match[field] !== null && match[field] !== undefined) { + sum += match[field]; + count += 1; + } + } + + return count > 0 ? sum / count : null; +} + +function round(value, decimals) { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +/** + * Builds urlDetails from the most recent date's data, + * with change values computed as current week avg minus previous week avg. + */ +function buildUrlDetails(dailyData, deviceType, log) { + const latestDay = dailyData[dailyData.length - 1]; + const latestUrls = filterUrls(latestDay.data, deviceType, log); + + const len = dailyData.length; + const currentWeekSlice = dailyData.slice(Math.max(0, len - CURRENT_WEEK_DAYS)); + const previousWeekSlice = dailyData.slice( + Math.max(0, len - 2 * CURRENT_WEEK_DAYS), + Math.max(0, len - CURRENT_WEEK_DAYS), + ); + + const fields = ['pageviews', 'lcp', 'cls', 'inp', 'bounceRate', 'engagement', 'clickRate']; + const pctFields = new Set(['bounceRate', 'engagement', 'clickRate']); + + return latestUrls.map((url, index) => { + const detail = { + id: String(index + 1), + url: url.url, + status: categorizeUrl(url.lcp, url.cls, url.inp), + }; + + for (const field of fields) { + const rawValue = url[field]; + const currentAvg = weeklyAvgForUrl(url.url, currentWeekSlice, deviceType, field, log); + const previousAvg = weeklyAvgForUrl(url.url, previousWeekSlice, deviceType, field, log); + + if (pctFields.has(field)) { + detail[field] = rawValue != null ? round(rawValue * 100, 1) : null; + detail[`${field}Change`] = (currentAvg != null && previousAvg != null) + ? round((currentAvg - previousAvg) * 100, 1) + : null; + } else { + detail[field] = rawValue != null ? round(rawValue, 3) : null; + detail[`${field}Change`] = (currentAvg != null && previousAvg != null) + ? round(currentAvg - previousAvg, 3) + : null; + } + } + + return detail; + }); +} + +function emptyResult(domain, deviceType, startDate, endDate) { + return { + auditResult: { + metadata: { + domain, + deviceType, + startDate: formatDate(startDate), + endDate: formatDate(endDate), + }, + trendData: [], + summary: { + good: { + current: 0, previous: 0, change: 0, percentageChange: 0, status: 'good', + }, + needsImprovement: { + current: 0, previous: 0, change: 0, percentageChange: 0, status: 'needsImprovement', + }, + poor: { + current: 0, previous: 0, change: 0, percentageChange: 0, status: 'poor', + }, + totalUrls: 0, + }, + urlDetails: [], + }, + fullAuditRef: `${S3_BASE_PATH}/`, + }; +} + +/** + * CWV Trends audit runner. + * Reads device type from site config (handler-level config under the audit type key). + * Falls back to DEFAULT_DEVICE_TYPE ('mobile') when not configured. + */ +export default async function cwvTrendsRunner(finalUrl, context, site) { + const { s3Client, log, env } = context; + const bucketName = env.S3_IMPORTER_BUCKET_NAME; + const domain = finalUrl; + + const handlerConfig = site.getConfig?.()?.getHandlers?.()?.[AUDIT_TYPE] || {}; + const deviceType = handlerConfig.deviceType || DEFAULT_DEVICE_TYPE; + + const endDate = new Date(); + const startDate = subtractDays(endDate, TREND_DAYS - 1); + + log.info(`[${AUDIT_TYPE}] siteId: ${site.getId()} | device: ${deviceType} | Reading ${TREND_DAYS} days of S3 data`); + + const dailyData = await readTrendData(s3Client, bucketName, endDate, TREND_DAYS, log); + + if (dailyData.length === 0) { + log.warn(`[${AUDIT_TYPE}] No S3 data found for any date`); + return emptyResult(domain, deviceType, startDate, endDate); + } + + const trendData = buildTrendData(dailyData, deviceType, log); + const latestUrls = filterUrls(dailyData[dailyData.length - 1].data, deviceType, log); + const totalUrls = latestUrls.length; + const summary = buildSummary(trendData, totalUrls); + const urlDetails = buildUrlDetails(dailyData, deviceType, log); + + log.info(`[${AUDIT_TYPE}] Processed ${totalUrls} URLs, ${dailyData.length} days of data`); + + return { + auditResult: { + metadata: { + domain, + deviceType, + startDate: formatDate(startDate), + endDate: formatDate(endDate), + }, + trendData, + summary, + urlDetails, + }, + fullAuditRef: `${S3_BASE_PATH}/`, + }; +} diff --git a/src/index.js b/src/index.js index 7b5c1a16cd..96d1e55f7d 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,7 @@ import accessibilityDesktop from './accessibility/handler-desktop.js'; import accessibilityMobile from './accessibility/handler-mobile.js'; import apex from './apex/handler.js'; import cwv from './cwv/handler.js'; +import cwvTrendsAudit from './cwv-trends-audit/handler.js'; import lhsDesktop from './lhs/handler-desktop.js'; import lhsMobile from './lhs/handler-mobile.js'; import sitemap from './sitemap/handler.js'; @@ -121,6 +122,7 @@ const HANDLERS = { 'accessibility-mobile': accessibilityMobile, apex, cwv, + 'cwv-trends-audit': cwvTrendsAudit, 'lhs-mobile': lhsMobile, 'lhs-desktop': lhsDesktop, sitemap, diff --git a/test/audits/cwv-trends-audit/constants.test.js b/test/audits/cwv-trends-audit/constants.test.js new file mode 100644 index 0000000000..65e3738feb --- /dev/null +++ b/test/audits/cwv-trends-audit/constants.test.js @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect } from 'chai'; +import { + AUDIT_TYPE, TREND_DAYS, CURRENT_WEEK_DAYS, S3_BASE_PATH, + MIN_PAGEVIEWS, DEFAULT_DEVICE_TYPE, CWV_THRESHOLDS, OPPORTUNITY_TITLES, +} from '../../../src/cwv-trends-audit/constants.js'; + +describe('CWV Trends Audit Constants', () => { + it('should define AUDIT_TYPE', () => { + expect(AUDIT_TYPE).to.equal('cwv-trends-audit'); + }); + + it('should define TREND_DAYS as 28', () => { + expect(TREND_DAYS).to.equal(28); + }); + + it('should define CURRENT_WEEK_DAYS as 7', () => { + expect(CURRENT_WEEK_DAYS).to.equal(7); + }); + + it('should define correct S3 base path', () => { + expect(S3_BASE_PATH).to.equal('metrics/cwv-trends'); + }); + + it('should define MIN_PAGEVIEWS as 1000', () => { + expect(MIN_PAGEVIEWS).to.equal(1000); + }); + + it('should define DEFAULT_DEVICE_TYPE as mobile', () => { + expect(DEFAULT_DEVICE_TYPE).to.equal('mobile'); + }); + + it('should define CWV thresholds', () => { + expect(CWV_THRESHOLDS.LCP).to.deep.equal({ GOOD: 2500, POOR: 4000 }); + expect(CWV_THRESHOLDS.CLS).to.deep.equal({ GOOD: 0.1, POOR: 0.25 }); + expect(CWV_THRESHOLDS.INP).to.deep.equal({ GOOD: 200, POOR: 500 }); + }); + + it('should define opportunity titles', () => { + expect(OPPORTUNITY_TITLES.mobile).to.equal('Mobile Web Performance Trends Report'); + expect(OPPORTUNITY_TITLES.desktop).to.equal('Desktop Web Performance Trends Report'); + }); +}); diff --git a/test/audits/cwv-trends-audit/cwv-categorizer.test.js b/test/audits/cwv-trends-audit/cwv-categorizer.test.js new file mode 100644 index 0000000000..2e48d49f3c --- /dev/null +++ b/test/audits/cwv-trends-audit/cwv-categorizer.test.js @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect } from 'chai'; +import { categorizeUrl } from '../../../src/cwv-trends-audit/cwv-categorizer.js'; + +describe('CWV Categorizer', () => { + describe('categorizeUrl', () => { + it('returns "good" when all metrics are within good thresholds', () => { + expect(categorizeUrl(2500, 0.1, 200)).to.equal('good'); + }); + + it('returns "poor" when LCP exceeds poor threshold', () => { + expect(categorizeUrl(4001, 0.05, 100)).to.equal('poor'); + }); + + it('returns "poor" when CLS exceeds poor threshold', () => { + expect(categorizeUrl(2000, 0.26, 100)).to.equal('poor'); + }); + + it('returns "poor" when INP exceeds poor threshold', () => { + expect(categorizeUrl(2000, 0.05, 501)).to.equal('poor'); + }); + + it('returns "needsImprovement" when between good and poor', () => { + expect(categorizeUrl(3000, 0.15, 300)).to.equal('needsImprovement'); + }); + + it('returns "needsImprovement" at exact poor boundary (not exceeding)', () => { + expect(categorizeUrl(4000, 0.25, 500)).to.equal('needsImprovement'); + }); + + it('returns null when all metrics are null', () => { + expect(categorizeUrl(null, null, null)).to.be.null; + }); + + it('returns null when all metrics are undefined', () => { + expect(categorizeUrl(undefined, undefined, undefined)).to.be.null; + }); + + it('categorizes with partial metrics (only LCP)', () => { + expect(categorizeUrl(2000, null, null)).to.equal('good'); + expect(categorizeUrl(5000, null, null)).to.equal('poor'); + expect(categorizeUrl(3000, null, null)).to.equal('needsImprovement'); + }); + + it('categorizes with partial metrics (only CLS)', () => { + expect(categorizeUrl(null, 0.05, null)).to.equal('good'); + expect(categorizeUrl(null, 0.30, null)).to.equal('poor'); + }); + + it('categorizes with partial metrics (only INP)', () => { + expect(categorizeUrl(null, null, 100)).to.equal('good'); + expect(categorizeUrl(null, null, 600)).to.equal('poor'); + }); + }); +}); diff --git a/test/audits/cwv-trends-audit/data-reader.test.js b/test/audits/cwv-trends-audit/data-reader.test.js new file mode 100644 index 0000000000..8d110d0d45 --- /dev/null +++ b/test/audits/cwv-trends-audit/data-reader.test.js @@ -0,0 +1,127 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('CWV Trends Data Reader', () => { + let sandbox; + let getObjectFromKeyStub; + let readTrendData; + let formatDate; + let subtractDays; + let log; + + const sampleData = [ + { url: 'https://example.com/p1', metrics: [{ deviceType: 'mobile', pageviews: 5000 }] }, + ]; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + getObjectFromKeyStub = sandbox.stub(); + log = { info: sandbox.spy(), warn: sandbox.spy(), error: sandbox.spy() }; + + const module = await esmock('../../../src/cwv-trends-audit/data-reader.js', { + '../../../src/utils/s3-utils.js': { getObjectFromKey: getObjectFromKeyStub }, + }); + + ({ readTrendData, formatDate, subtractDays } = module); + }); + + afterEach(() => { sandbox.restore(); }); + + describe('formatDate', () => { + it('formats a date as YYYY-MM-DD', () => { + expect(formatDate(new Date('2025-11-15T00:00:00Z'))).to.equal('2025-11-15'); + }); + }); + + describe('subtractDays', () => { + it('subtracts days without mutating original', () => { + const date = new Date('2025-11-15T00:00:00Z'); + const result = subtractDays(date, 7); + expect(formatDate(result)).to.equal('2025-11-08'); + expect(formatDate(date)).to.equal('2025-11-15'); + }); + }); + + describe('readTrendData', () => { + it('reads data for the specified number of days', async () => { + getObjectFromKeyStub.resolves(sampleData); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 3, log); + expect(result).to.have.lengthOf(3); + expect(getObjectFromKeyStub).to.have.callCount(3); + }); + + it('returns results in chronological order', async () => { + getObjectFromKeyStub.resolves(sampleData); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 3, log); + expect(result[0].date).to.equal('2025-11-26'); + expect(result[2].date).to.equal('2025-11-28'); + }); + + it('skips dates with missing data', async () => { + getObjectFromKeyStub.onFirstCall().resolves(sampleData).onSecondCall().resolves(null) + .onThirdCall().resolves(sampleData); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 3, log); + expect(result).to.have.lengthOf(2); + expect(log.warn).to.have.been.called; + }); + + it('handles JSON string response from S3', async () => { + getObjectFromKeyStub.resolves(JSON.stringify(sampleData)); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 1, log); + expect(result).to.have.lengthOf(1); + expect(result[0].data).to.deep.equal(sampleData); + }); + + it('skips invalid JSON strings', async () => { + getObjectFromKeyStub.resolves('not-json'); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 1, log); + expect(result).to.have.lengthOf(0); + }); + + it('skips non-array responses', async () => { + getObjectFromKeyStub.resolves({ notAnArray: true }); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 1, log); + expect(result).to.have.lengthOf(0); + }); + + it('returns empty array when all dates are missing', async () => { + getObjectFromKeyStub.resolves(null); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 3, log); + expect(result).to.have.lengthOf(0); + }); + + it('handles getObjectFromKey rejecting', async () => { + getObjectFromKeyStub.rejects(new Error('boom')); + const result = await readTrendData({}, 'bucket', new Date('2025-11-28T00:00:00Z'), 2, log); + expect(result).to.have.lengthOf(0); + expect(log.warn).to.have.been.calledTwice; + }); + + it('constructs correct S3 keys', async () => { + getObjectFromKeyStub.resolves(sampleData); + await readTrendData('s3', 'bucket', new Date('2025-11-28T00:00:00Z'), 1, log); + expect(getObjectFromKeyStub).to.have.been.calledWith( + 's3', 'bucket', + 'metrics/cwv-trends/cwv-trends-daily-2025-11-28.json', + log, + ); + }); + }); +}); diff --git a/test/audits/cwv-trends-audit/handler.test.js b/test/audits/cwv-trends-audit/handler.test.js new file mode 100644 index 0000000000..6193caaf15 --- /dev/null +++ b/test/audits/cwv-trends-audit/handler.test.js @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect } from 'chai'; + +describe('CWV Trends Audit Handler', () => { + it('exports a RunnerAudit instance as default', async () => { + const module = await import('../../../src/cwv-trends-audit/handler.js'); + expect(module.default).to.be.an('object'); + expect(module.default.constructor.name).to.equal('RunnerAudit'); + }); +}); diff --git a/test/audits/cwv-trends-audit/opportunity-data-mapper.test.js b/test/audits/cwv-trends-audit/opportunity-data-mapper.test.js new file mode 100644 index 0000000000..add830fe9e --- /dev/null +++ b/test/audits/cwv-trends-audit/opportunity-data-mapper.test.js @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect } from 'chai'; +import { createOpportunityData } from '../../../src/cwv-trends-audit/opportunity-data-mapper.js'; + +describe('CWV Trends Opportunity Data Mapper', () => { + it('creates mobile opportunity with correct title', () => { + const result = createOpportunityData({ deviceType: 'mobile' }); + expect(result.title).to.equal('Mobile Web Performance Trends Report'); + expect(result.data.deviceType).to.equal('mobile'); + }); + + it('creates desktop opportunity with correct title', () => { + const result = createOpportunityData({ deviceType: 'desktop' }); + expect(result.title).to.equal('Desktop Web Performance Trends Report'); + }); + + it('falls back to mobile title for unknown device type', () => { + const result = createOpportunityData({ deviceType: 'unknown' }); + expect(result.title).to.equal('Mobile Web Performance Trends Report'); + }); + + it('includes guidance, tags, origin, and data sources', () => { + const result = createOpportunityData({ deviceType: 'mobile' }); + expect(result.origin).to.equal('AUTOMATION'); + expect(result.guidance.steps).to.be.an('array').that.is.not.empty; + expect(result.tags).to.include('CWV'); + expect(result.data.dataSources).to.be.an('array').with.lengthOf(2); + expect(result.description).to.be.a('string').that.is.not.empty; + }); +}); diff --git a/test/audits/cwv-trends-audit/opportunity-handler.test.js b/test/audits/cwv-trends-audit/opportunity-handler.test.js new file mode 100644 index 0000000000..4b3a5f7f5c --- /dev/null +++ b/test/audits/cwv-trends-audit/opportunity-handler.test.js @@ -0,0 +1,147 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('CWV Trends Opportunity Handler', () => { + let sandbox; + let convertToOpportunityStub; + let syncSuggestionsStub; + let opportunityHandler; + + const mockOpportunity = { getId: () => 'opp-123' }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + convertToOpportunityStub = sandbox.stub().resolves(mockOpportunity); + syncSuggestionsStub = sandbox.stub().resolves(); + + const module = await esmock('../../../src/cwv-trends-audit/opportunity-handler.js', { + '../../../src/common/opportunity.js': { convertToOpportunity: convertToOpportunityStub }, + '../../../src/utils/data-access.js': { syncSuggestions: syncSuggestionsStub }, + }); + + opportunityHandler = module.default; + }); + + afterEach(() => { sandbox.restore(); }); + + it('calls convertToOpportunity with correct audit type, device type, and comparisonFn', async () => { + const auditData = { + auditResult: { + metadata: { deviceType: 'mobile' }, + urlDetails: [{ url: 'https://ex.com/p1', pageviews: 5000 }], + }, + }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + expect(convertToOpportunityStub).to.have.been.calledOnce; + const [, , , , auditType, props, comparisonFn] = convertToOpportunityStub.firstCall.args; + expect(auditType).to.equal('cwv-trends-audit'); + expect(props).to.deep.equal({ deviceType: 'mobile' }); + expect(comparisonFn).to.be.a('function'); + }); + + it('comparisonFn matches by opportunity title for mobile', async () => { + const auditData = { + auditResult: { + metadata: { deviceType: 'mobile' }, + urlDetails: [], + }, + }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + const comparisonFn = convertToOpportunityStub.firstCall.args[6]; + expect(comparisonFn({ getTitle: () => 'Mobile Web Performance Trends Report' })).to.be.true; + expect(comparisonFn({ getTitle: () => 'Desktop Web Performance Trends Report' })).to.be.false; + }); + + it('comparisonFn matches by opportunity title for desktop', async () => { + const auditData = { + auditResult: { + metadata: { deviceType: 'desktop' }, + urlDetails: [], + }, + }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + const comparisonFn = convertToOpportunityStub.firstCall.args[6]; + expect(comparisonFn({ getTitle: () => 'Desktop Web Performance Trends Report' })).to.be.true; + expect(comparisonFn({ getTitle: () => 'Mobile Web Performance Trends Report' })).to.be.false; + }); + + it('comparisonFn defaults to mobile title for unknown device type', async () => { + const auditData = { + auditResult: { + metadata: { deviceType: 'unknown' }, + urlDetails: [], + }, + }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + const comparisonFn = convertToOpportunityStub.firstCall.args[6]; + expect(comparisonFn({ getTitle: () => 'Mobile Web Performance Trends Report' })).to.be.true; + }); + + it('syncs suggestions with urlDetails', async () => { + const urlDetails = [ + { url: 'https://ex.com/p1', pageviews: 5000 }, + { url: 'https://ex.com/p2', pageviews: 3000 }, + ]; + const auditData = { auditResult: { metadata: { deviceType: 'desktop' }, urlDetails } }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + expect(syncSuggestionsStub).to.have.been.calledOnce; + const args = syncSuggestionsStub.firstCall.args[0]; + expect(args.opportunity).to.equal(mockOpportunity); + expect(args.newData).to.equal(urlDetails); + }); + + it('maps suggestions correctly', async () => { + const urlDetails = [{ url: 'https://ex.com/p1', pageviews: 5000, lcp: 2000 }]; + const auditData = { auditResult: { metadata: { deviceType: 'mobile' }, urlDetails } }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + await opportunityHandler('https://ex.com', auditData, context); + + const { mapNewSuggestion, buildKey } = syncSuggestionsStub.firstCall.args[0]; + const suggestion = mapNewSuggestion(urlDetails[0]); + expect(suggestion.opportunityId).to.equal('opp-123'); + expect(suggestion.type).to.equal('CONTENT_UPDATE'); + expect(suggestion.rank).to.equal(5000); + expect(buildKey(urlDetails[0])).to.equal('https://ex.com/p1'); + }); + + it('returns auditData', async () => { + const auditData = { auditResult: { metadata: { deviceType: 'mobile' }, urlDetails: [] } }; + const context = { dataAccess: {}, log: { info: sinon.spy() } }; + + const result = await opportunityHandler('https://ex.com', auditData, context); + expect(result).to.equal(auditData); + }); +}); diff --git a/test/audits/cwv-trends-audit/utils.test.js b/test/audits/cwv-trends-audit/utils.test.js new file mode 100644 index 0000000000..402b2322a6 --- /dev/null +++ b/test/audits/cwv-trends-audit/utils.test.js @@ -0,0 +1,340 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('CWV Trends Audit Runner (utils.js)', () => { + let sandbox; + let cwvTrendsRunner; + let readTrendDataStub; + let log; + + function buildUrl(url, deviceType, overrides = {}) { + return { + url, + metrics: [{ + deviceType, + pageviews: 5000, + bounceRate: 0.25, + engagement: 0.75, + clickRate: 0.60, + lcp: 2000, + cls: 0.08, + inp: 180, + ...overrides, + }], + }; + } + + function buildDays(dates, urls) { + return dates.map((date) => ({ date, data: urls })); + } + + function makeDates(count, start = '2025-11-01') { + return Array.from({ length: count }, (_, i) => { + const d = new Date(start); + d.setDate(d.getDate() + i); + return d.toISOString().split('T')[0]; + }); + } + + function makeSite(handlerConfig = {}) { + return { + getId: () => 'site-1', + getConfig: () => ({ + getHandlers: () => ({ 'cwv-trends-audit': handlerConfig }), + }), + }; + } + + function makeContext() { + return { s3Client: {}, log, env: { S3_IMPORTER_BUCKET_NAME: 'bucket' } }; + } + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + readTrendDataStub = sandbox.stub(); + log = { info: sandbox.spy(), warn: sandbox.spy(), error: sandbox.spy() }; + + const module = await esmock('../../../src/cwv-trends-audit/utils.js', { + '../../../src/cwv-trends-audit/data-reader.js': { + readTrendData: readTrendDataStub, + formatDate: (d) => d.toISOString().split('T')[0], + subtractDays: (d, n) => { const r = new Date(d); r.setDate(r.getDate() - n); return r; }, + }, + }); + + cwvTrendsRunner = module.default; + }); + + afterEach(() => { sandbox.restore(); }); + + it('produces audit result with correct structure', async () => { + const urls = [buildUrl('https://ex.com/p1', 'mobile')]; + readTrendDataStub.resolves(buildDays(makeDates(28), urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult).to.have.all.keys('metadata', 'trendData', 'summary', 'urlDetails'); + expect(result.auditResult.metadata.deviceType).to.equal('mobile'); + expect(result.auditResult.trendData).to.have.lengthOf(28); + expect(result).to.have.property('fullAuditRef'); + }); + + it('reads device type from site config', async () => { + const urls = [buildUrl('https://ex.com/p1', 'desktop')]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner( + 'https://ex.com', + makeContext(), + makeSite({ deviceType: 'desktop' }), + ); + + expect(result.auditResult.metadata.deviceType).to.equal('desktop'); + expect(result.auditResult.urlDetails).to.have.lengthOf(1); + }); + + it('defaults to mobile when config has no deviceType', async () => { + const urls = [buildUrl('https://ex.com/p1', 'mobile')]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.metadata.deviceType).to.equal('mobile'); + }); + + it('defaults to mobile when site has no config', async () => { + const urls = [buildUrl('https://ex.com/p1', 'mobile')]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const site = { getId: () => 'site-1', getConfig: () => null }; + const result = await cwvTrendsRunner('https://ex.com', makeContext(), site); + + expect(result.auditResult.metadata.deviceType).to.equal('mobile'); + }); + + it('defaults to mobile when getConfig is undefined', async () => { + const urls = [buildUrl('https://ex.com/p1', 'mobile')]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const site = { getId: () => 'site-1' }; + const result = await cwvTrendsRunner('https://ex.com', makeContext(), site); + + expect(result.auditResult.metadata.deviceType).to.equal('mobile'); + }); + + it('filters URLs by device type', async () => { + const urls = [ + buildUrl('https://ex.com/m', 'mobile'), + buildUrl('https://ex.com/d', 'desktop'), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.urlDetails).to.have.lengthOf(1); + expect(result.auditResult.urlDetails[0].url).to.equal('https://ex.com/m'); + }); + + it('filters URLs below MIN_PAGEVIEWS', async () => { + const urls = [ + buildUrl('https://ex.com/high', 'mobile', { pageviews: 5000 }), + buildUrl('https://ex.com/low', 'mobile', { pageviews: 500 }), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.urlDetails).to.have.lengthOf(1); + expect(result.auditResult.urlDetails[0].url).to.equal('https://ex.com/high'); + }); + + it('sorts URLs by pageviews descending with sequential IDs', async () => { + const urls = [ + buildUrl('https://ex.com/low', 'mobile', { pageviews: 2000 }), + buildUrl('https://ex.com/high', 'mobile', { pageviews: 8000 }), + buildUrl('https://ex.com/mid', 'mobile', { pageviews: 5000 }), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.urlDetails[0].url).to.equal('https://ex.com/high'); + expect(result.auditResult.urlDetails[0].id).to.equal('1'); + expect(result.auditResult.urlDetails[1].url).to.equal('https://ex.com/mid'); + expect(result.auditResult.urlDetails[1].id).to.equal('2'); + expect(result.auditResult.urlDetails[2].url).to.equal('https://ex.com/low'); + expect(result.auditResult.urlDetails[2].id).to.equal('3'); + }); + + it('includes CWV status (good/needsImprovement/poor) per URL in urlDetails', async () => { + const urls = [ + buildUrl('https://ex.com/good', 'mobile', { pageviews: 5000, lcp: 2000, cls: 0.05, inp: 100 }), + buildUrl('https://ex.com/ni', 'mobile', { pageviews: 4000, lcp: 3000, cls: 0.15, inp: 300 }), + buildUrl('https://ex.com/poor', 'mobile', { pageviews: 3000, lcp: 5000, cls: 0.30, inp: 600 }), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const details = result.auditResult.urlDetails; + + expect(details[0].status).to.equal('good'); + expect(details[1].status).to.equal('needsImprovement'); + expect(details[2].status).to.equal('poor'); + }); + + it('sets status to null when all CWV metrics are null', async () => { + const urls = [buildUrl('https://ex.com/p', 'mobile', { + pageviews: 5000, lcp: null, cls: null, inp: null, + })]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.urlDetails[0].status).to.be.null; + }); + + it('converts bounceRate, engagement, clickRate to percentages', async () => { + const urls = [buildUrl('https://ex.com/p', 'mobile', { + pageviews: 5000, bounceRate: 0.253, engagement: 0.785, clickRate: 0.760, + })]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const d = result.auditResult.urlDetails[0]; + + expect(d.bounceRate).to.equal(25.3); + expect(d.engagement).to.equal(78.5); + expect(d.clickRate).to.equal(76); + }); + + it('handles null percentage fields', async () => { + const urls = [buildUrl('https://ex.com/p', 'mobile', { + pageviews: 5000, bounceRate: null, engagement: null, clickRate: null, + })]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const d = result.auditResult.urlDetails[0]; + + expect(d.bounceRate).to.be.null; + expect(d.engagement).to.be.null; + expect(d.clickRate).to.be.null; + }); + + it('counts CWV categories per day in trendData', async () => { + const urls = [ + buildUrl('https://ex.com/good', 'mobile', { lcp: 2000, cls: 0.05, inp: 100 }), + buildUrl('https://ex.com/poor', 'mobile', { pageviews: 3000, lcp: 5000, cls: 0.30, inp: 600 }), + buildUrl('https://ex.com/ni', 'mobile', { pageviews: 2000, lcp: 3000, cls: 0.15, inp: 300 }), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const t = result.auditResult.trendData[0]; + + expect(t.good).to.equal(1); + expect(t.needsImprovement).to.equal(1); + expect(t.poor).to.equal(1); + }); + + it('builds summary with current and previous week comparison', async () => { + const urls = [buildUrl('https://ex.com/p', 'mobile', { lcp: 2000, cls: 0.05, inp: 100 })]; + readTrendDataStub.resolves(buildDays(makeDates(28), urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const { summary } = result.auditResult; + + expect(summary.good).to.have.all.keys('current', 'previous', 'change', 'percentageChange', 'status'); + expect(summary.good.status).to.equal('good'); + expect(summary.needsImprovement.status).to.equal('needsImprovement'); + expect(summary.poor.status).to.equal('poor'); + expect(summary.totalUrls).to.equal(1); + }); + + it('returns empty result when no S3 data', async () => { + readTrendDataStub.resolves([]); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.trendData).to.deep.equal([]); + expect(result.auditResult.urlDetails).to.deep.equal([]); + expect(result.auditResult.summary.totalUrls).to.equal(0); + expect(log.warn).to.have.been.calledWith(sinon.match(/No S3 data found/)); + }); + + it('skips URLs with undefined device type', async () => { + const urls = [buildUrl('https://ex.com/p', 'undefined', { pageviews: 5000 })]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const site = { + getId: () => 'site-1', + getConfig: () => ({ + getHandlers: () => ({ 'cwv-trends-audit': { deviceType: 'undefined' } }), + }), + }; + const result = await cwvTrendsRunner('https://ex.com', makeContext(), site); + + expect(result.auditResult.urlDetails).to.have.lengthOf(0); + expect(log.warn).to.have.been.calledWith(sinon.match(/undefined device type/)); + }); + + it('handles null CWV metrics in categorization', async () => { + const urls = [ + buildUrl('https://ex.com/nulls', 'mobile', { lcp: null, cls: null, inp: null }), + buildUrl('https://ex.com/good', 'mobile', { pageviews: 3000, lcp: 2000, cls: 0.05, inp: 100 }), + ]; + readTrendDataStub.resolves(buildDays(['2025-11-28'], urls)); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + + expect(result.auditResult.urlDetails).to.have.lengthOf(2); + expect(result.auditResult.trendData[0].good).to.equal(1); + expect(result.auditResult.trendData[0].poor).to.equal(0); + }); + + it('computes change as current week avg minus previous week avg', async () => { + const prev = [buildUrl('https://ex.com/p', 'mobile', { + pageviews: 4000, lcp: 2000, bounceRate: 0.30, engagement: 0.70, clickRate: 0.50, + cls: 0.10, inp: 200, + })]; + const curr = [buildUrl('https://ex.com/p', 'mobile', { + pageviews: 6000, lcp: 2500, bounceRate: 0.20, engagement: 0.80, clickRate: 0.60, + cls: 0.05, inp: 150, + })]; + + const dailyData = []; + for (let i = 0; i < 7; i += 1) { + const d = new Date('2025-11-14'); d.setDate(d.getDate() + i); + dailyData.push({ date: d.toISOString().split('T')[0], data: prev }); + } + for (let i = 0; i < 7; i += 1) { + const d = new Date('2025-11-21'); d.setDate(d.getDate() + i); + dailyData.push({ date: d.toISOString().split('T')[0], data: curr }); + } + readTrendDataStub.resolves(dailyData); + + const result = await cwvTrendsRunner('https://ex.com', makeContext(), makeSite()); + const detail = result.auditResult.urlDetails[0]; + + expect(detail.pageviewsChange).to.equal(2000); + expect(detail.lcpChange).to.equal(500); + expect(detail.bounceRateChange).to.equal(-10); + }); +});