diff --git a/config/techreport.json b/config/techreport.json index e8580669..4b2456a5 100644 --- a/config/techreport.json +++ b/config/techreport.json @@ -13,14 +13,21 @@ "LCP", "FCP", "INP", - "TTFB" + "TTFB", + "overall_sn", + "LCP_sn", + "CLS_sn", + "INP_sn" ], "cwv_thresholds": { "LCP": [{ "value": 2500, "label": "Good" }, { "value": 4000, "label": "Needs improvement" }], + "LCP_sn": [{ "value": 2500, "label": "Good" }, { "value": 4000, "label": "Needs improvement" }], "FCP": [{ "value": 1800, "label": "Good" }, { "value": 3000, "label": "Needs improvement" }], "TTFB": [{ "value": 800, "label": "Good" }, { "value": 1800, "label": "Needs improvement" }], "INP": [{ "value": 200, "label": "Good" }, { "value": 500, "label": "Needs improvement" }], - "CLS": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }] + "INP_sn": [{ "value": 200, "label": "Good" }, { "value": 500, "label": "Needs improvement" }], + "CLS": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }], + "CLS_sn": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }] }, "lighthouse_subcategories": [ "accessibility", @@ -324,6 +331,22 @@ { "label": "Good TTFB", "value": "TTFB" + }, + { + "label": "Good Core Web Vitals (with soft navs)", + "value": "overall_sn" + }, + { + "label": "Good LCP (with soft navs)", + "value": "LCP_sn" + }, + { + "label": "Good INP (with soft navs)", + "value": "INP_sn" + }, + { + "label": "Good CLS (with soft navs)", + "value": "CLS_sn" } ] }, @@ -739,7 +762,11 @@ "INP": "Good INP", "CLS": "Good CLS", "FCP": "Good FCP", - "TTFB": "Good TTFB" + "TTFB": "Good TTFB", + "overall_sn": "Good Core Web Vitals (with soft navs)", + "LCP_sn": "Good LCP (with soft navs)", + "INP_sn": "Good INP (with soft navs)", + "CLS_sn": "Good CLS (with soft navs)" } }, "cwv_distribution": { @@ -755,6 +782,14 @@ "axis_label": "LCP (ms)", "step": 100 }, + "LCP_sn": { + "label": "LCP (soft navs)", + "bucketField": "loading_bucket", + "originsField": "lcp_sn_origins", + "unit": "ms", + "axis_label": "LCP with soft navs (ms)", + "step": 100 + }, "FCP": { "label": "FCP", "bucketField": "loading_bucket", @@ -778,6 +813,14 @@ "axis_label": "INP (ms)", "step": 25 }, + "INP_sn": { + "label": "INP (soft navs)", + "bucketField": "inp_bucket", + "originsField": "inp_sn_origins", + "unit": "ms", + "axis_label": "INP with soft navs (ms)", + "step": 25 + }, "CLS": { "label": "CLS", "bucketField": "cls_bucket", @@ -785,6 +828,14 @@ "unit": "", "axis_label": "CLS", "step": 0.05 + }, + "CLS_sn": { + "label": "CLS (soft navs)", + "bucketField": "cls_bucket", + "originsField": "cls_sn_origins", + "unit": "", + "axis_label": "CLS with soft navs", + "step": 0.05 } }, "zone_colors": { @@ -938,6 +989,22 @@ { "label": "Good TTFB", "value": "TTFB" + }, + { + "label": "Good Core Web Vitals (with soft navs)", + "value": "overall_sn" + }, + { + "label": "Good LCP (with soft navs)", + "value": "LCP_sn" + }, + { + "label": "Good INP (with soft navs)", + "value": "INP_sn" + }, + { + "label": "Good CLS (with soft navs)", + "value": "CLS_sn" } ] }, @@ -1440,6 +1507,26 @@ "label": "Good TTFB", "title": "Good Time To First Byte", "description": "TTFB is a metric that measures the time between the request for a resource and when the first byte of a response begins to arrive. A good TTFB is less than or equal to 800ms." + }, + "overall_sn": { + "label": "Good Core Web Vitals (with soft navs)", + "title": "Good Core Web Vitals (with soft navs)", + "description": "The percentage of origins passing all three Core Web Vitals (LCP, INP, CLS) with a good experience, accounting for soft navigations in single-page applications. Note that if an origin is missing INP data, it's assessed based on the performance of the remaining metrics." + }, + "LCP_sn": { + "label": "Good LCP (with soft navs)", + "title": "Good Largest Contentful Paint (with soft navs)", + "description": "Largest Contentful Paint including soft navigations measures perceived load speed across both hard page loads and subsequent in-app soft navigations. Good experiences are less than or equal to 2.5 seconds." + }, + "INP_sn": { + "label": "Good INP (with soft navs)", + "title": "Good Interaction to Next Paint (with soft navs)", + "description": "Interaction to Next Paint including soft navigations assesses responsiveness across user interactions within the lifespan of a visit, including interactions occurring during soft navigations. A good experience is less than or equal to 200ms." + }, + "CLS_sn": { + "label": "Good CLS (with soft navs)", + "title": "Good Cumulative Layout Shift (with soft navs)", + "description": "Cumulative Layout Shift including soft navigations measures visual stability across both initial loads and subsequent soft navigation page changes. Sites should strive to have a CLS of 0.1 or less for at least 75% of page visits." } }, "pageWeight": { @@ -1480,6 +1567,10 @@ "origins_eligible_for_fcp": "Eligible", "origins_eligible_for_inp": "Eligible", "origins_eligible_for_ttfb": "Eligible", + "origins_eligible_for_cwv_sn": "Eligible", + "origins_eligible_for_cls_sn": "Eligible", + "origins_eligible_for_lcp_sn": "Eligible", + "origins_eligible_for_inp_sn": "Eligible", "origins_with_good_cwv": "Having good Core Web Vitals", "origins_with_good_cls": "Having good CLS", @@ -1487,6 +1578,10 @@ "origins_with_good_fcp": "Having good FCP", "origins_with_good_inp": "Having good INP", "origins_with_good_ttfb": "Having good TTFB", + "origins_with_good_cwv_sn": "Having good Core Web Vitals (with soft navs)", + "origins_with_good_cls_sn": "Having good CLS (with soft navs)", + "origins_with_good_lcp_sn": "Having good LCP (with soft navs)", + "origins_with_good_inp_sn": "Having good INP (with soft navs)", "pct_good_cwv": "% Good Core Web Vitals", "pct_good_cls": "% Good CLS", @@ -1494,6 +1589,10 @@ "pct_good_fcp": "% Good FCP", "pct_good_inp": "% Good INP", "pct_good_ttfb": "% Good TTFB", + "pct_good_cwv_sn": "% Good Core Web Vitals (with soft navs)", + "pct_good_cls_sn": "% Good CLS (with soft navs)", + "pct_good_lcp_sn": "% Good LCP (with soft navs)", + "pct_good_inp_sn": "% Good INP (with soft navs)", "median_lighthouse_score_accessibility": "Median accessibility", "median_lighthouse_score_performance": "Median performance", diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js index 99cc9b07..61f72954 100644 --- a/src/js/techreport/cwvDistribution.js +++ b/src/js/techreport/cwvDistribution.js @@ -50,23 +50,34 @@ class CwvDistribution { } this.updateTitle(); + this.updateButton(); this.bindEventListeners(); // Auto-expand if URL hash targets this section - if (window.location.hash === `#section-${this.id}`) { + if (this.selectedMetric && window.location.hash === `#section-${this.id}`) { this.toggle(true); } } - // Map the shared metric value (which may be 'overall') to a metric this chart can show + // Map the shared metric value to a metric this chart can show, or null if not available resolveMetric(value) { - if (value && this.pageConfig.cwv_distribution.metric_config[value]) return this.pageConfig.cwv_distribution.metric_config[value].label; - return 'LCP'; + if (value && this.pageConfig?.cwv_distribution?.metric_config?.[value]) return value; + return null; + } + + updateButton() { + const btn = document.getElementById('cwv-distribution-btn'); + if (btn) { + btn.disabled = !this.selectedMetric; + } } updateTitle() { const slot = this.root?.querySelector('[data-slot="cwv-distribution-title-metric"]'); - if (slot) slot.textContent = this.selectedMetric; + if (slot && this.selectedMetric) { + const cfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric]; + slot.textContent = cfg?.label || this.selectedMetric; + } } bindEventListeners() { @@ -77,7 +88,14 @@ class CwvDistribution { if (resolved !== this.selectedMetric) { this.selectedMetric = resolved; this.updateTitle(); - if (this.distributionData) this.renderChart(); + this.updateButton(); + if (!this.selectedMetric) { + if (!this.root.classList.contains('hidden')) { + this.toggle(false); + } + } else if (this.distributionData) { + this.renderChart(); + } } }); @@ -93,6 +111,7 @@ class CwvDistribution { toggle(show) { const btn = document.getElementById('cwv-distribution-btn'); if (show) { + if (!this.selectedMetric) return; this.root.classList.remove('hidden'); if (btn) btn.textContent = 'Hide histogram'; if (!this.date) { @@ -175,6 +194,10 @@ class CwvDistribution { } showError() { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } if (!this.chartContainer) return; this.chartContainer.innerHTML = '
Distribution data is not available for this selection.
'; } @@ -207,14 +230,14 @@ class CwvDistribution { } trimWithOverflow(rows, originsField, percentile) { - const total = rows.reduce((sum, row) => sum + row[originsField], 0); - if (total === 0) return { visible: rows, overflowCount: 0 }; + const total = rows.reduce((sum, row) => sum + (row[originsField] || 0), 0); + if (!total || isNaN(total)) return { visible: rows, overflowCount: 0, total: 0 }; const cutoff = total * percentile; let cumulative = 0; let cutIndex = rows.length; for (let i = 0; i < rows.length; i++) { - cumulative += rows[i][originsField]; + cumulative += (rows[i][originsField] || 0); if (cumulative >= cutoff) { cutIndex = Math.min(i + 2, rows.length); break; @@ -222,8 +245,8 @@ class CwvDistribution { } const visible = rows.slice(0, cutIndex); - const visibleSum = visible.reduce((sum, row) => sum + row[originsField], 0); - return { visible, overflowCount: total - visibleSum }; + const visibleSum = visible.reduce((sum, row) => sum + (row[originsField] || 0), 0); + return { visible, overflowCount: total - visibleSum, total }; } renderChart() { @@ -231,17 +254,31 @@ class CwvDistribution { if (!this.root) return; const client = this.root.dataset.client || 'mobile'; - const metricCfg = this.pageConfig.cwv_distribution.metric_config[this.selectedMetric]; - const thresholds = this.config.cwv_thresholds[this.selectedMetric]; + const metricCfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric]; + if (!metricCfg) { + this.showError(); + return; + } + const thresholds = this.config?.cwv_thresholds?.[this.selectedMetric] || []; const clientRows = this.distributionData .filter(row => row.client === client) .sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]); - const { visible, overflowCount } = this.trimWithOverflow( + if (clientRows.length === 0) { + this.showError(); + return; + } + + const { visible, overflowCount, total } = this.trimWithOverflow( clientRows, metricCfg.originsField, 0.995 ); + if (!total || total <= 0) { + this.showError(); + return; + } + const formatBucket = (val) => { if (metricCfg.unit === 'ms') { return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`; diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js index dea955f9..02b838ec 100644 --- a/src/js/techreport/geoBreakdown.js +++ b/src/js/techreport/geoBreakdown.js @@ -56,7 +56,13 @@ class GeoBreakdown { updateTitle() { const slot = document.querySelector('[data-slot="geo-title-metric"]'); if (!slot) return; - slot.textContent = this.selectedMetric === 'overall' ? 'Core Web Vitals' : this.selectedMetric; + if (this.selectedMetric === 'overall') { + slot.textContent = 'Core Web Vitals'; + } else if (this.selectedMetric === 'overall_sn') { + slot.textContent = 'Core Web Vitals (soft navs)'; + } else { + slot.textContent = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric]?.label || this.selectedMetric; + } } bindEventListeners() { @@ -137,16 +143,18 @@ class GeoBreakdown { // Determine bar label and which extra columns to show const metric = this.selectedMetric; - const barLabel = metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`; + const barLabel = this.pageConfig?.geo_breakdown?.metric_labels?.[metric] || `Good ${metric}`; const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric) ? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase()) : metric === 'overall' ? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }] - : []; // INP/CLS: no extra columns + : metric === 'overall_sn' + ? [{ key: 'lcp_sn', label: 'LCP' }, { key: 'inp_sn', label: 'INP' }, { key: 'cls_sn', label: 'CLS' }] + : []; // INP/CLS/individual softnav metrics: no extra columns // If current sort column is no longer visible, reset to total const extraKeys = extraCols.map(c => c.key); - if (['lcp', 'inp', 'cls', 'fcp', 'ttfb'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) { + if (['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'lcp_sn', 'inp_sn', 'cls_sn'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) { this.sortColumn = 'total'; this.sortDir = 'desc'; } @@ -171,7 +179,19 @@ class GeoBreakdown { const v = row.vitals?.find(x => x.name === name)?.[client]; return v?.tested > 0 ? Math.round(v.good_number / v.tested * 100) : null; }; - return { geo, good: d.good_number, total: d.tested, lcp: pctFor('LCP'), inp: pctFor('INP'), cls: pctFor('CLS'), fcp: pctFor('FCP'), ttfb: pctFor('TTFB') }; + return { + geo, + good: d.good_number, + total: d.tested, + lcp: pctFor('LCP'), + inp: pctFor('INP'), + cls: pctFor('CLS'), + fcp: pctFor('FCP'), + ttfb: pctFor('TTFB'), + lcp_sn: pctFor('LCP_sn'), + inp_sn: pctFor('INP_sn'), + cls_sn: pctFor('CLS_sn') + }; }).filter(e => e.total > 0); // Sort by origins, limit to top 10 unless showAll diff --git a/src/js/techreport/table.js b/src/js/techreport/table.js index 5433b5f0..bd394244 100644 --- a/src/js/techreport/table.js +++ b/src/js/techreport/table.js @@ -58,8 +58,15 @@ function formatData(tableConfig, data) { } }); - // Add row to the table - table.push(row); + // Only add row if at least one metric column has available data + const hasMetricData = row.some(cell => { + if (cell.key === 'date' || cell.key === 'client') return false; + return cell.value !== undefined && cell.value !== null && cell.value !== ''; + }); + + if (hasMetricData) { + table.push(row); + } } const sorted = sortTableBy(table, 'date'); @@ -72,7 +79,7 @@ function sortTableBy(dataset, key) { const selectedA = a.find(obj => obj.key === key); const selectedB = b.find(obj => obj.key === key); - return selectedA.value > selectedB.value ? -1 : 1; + return (selectedA?.value || '') > (selectedB?.value || '') ? -1 : 1; }); } @@ -82,7 +89,12 @@ function getColumnCell(columnConfig, data, date) { const _data = data[app]?.find(entry => entry.date === date); const endpointData = _data?.[endpoint]; const subcategoryData = endpointData?.find(row => row.name === subcategory); - const value = subcategoryData?.[client]?.[column.key]; + let value = subcategoryData?.[client]?.[column.key]; + + if (column.key === 'date' && !value && subcategoryData) { + value = date; + } + let styling; if(column.styleChange && column.keyNr) { @@ -183,6 +195,21 @@ function updateTable(id, config, appConfig, apps, data) { // Format the data per row const rows = formatData(tableConfig, data); + if (!rows || rows.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + let totalCols = 0; + config?.columns?.forEach(col => { + totalCols += col.breakdown === 'app' ? (apps?.length || 1) : 1; + }); + td.colSpan = totalCols || 1; + td.className = 'no-data'; + td.textContent = 'No data available for this selection.'; + tr.appendChild(td); + tbody.appendChild(tr); + return; + } + // Add rows to the table rows?.forEach(row => { const tr = document.createElement('tr'); diff --git a/src/js/techreport/timeseries.js b/src/js/techreport/timeseries.js index 51dfdcb6..6886228e 100644 --- a/src/js/techreport/timeseries.js +++ b/src/js/techreport/timeseries.js @@ -5,6 +5,13 @@ import { DataUtils } from "./utils/data"; import { UIUtils } from "./utils/ui"; import { UrlUtils } from "./utils/url"; +const HARD_NAV_MAP = { + overall_sn: 'overall', + LCP_sn: 'LCP', + INP_sn: 'INP', + CLS_sn: 'CLS', +}; + class Timeseries { // Create the component constructor(id, pageConfig, config, filters, data) { @@ -499,6 +506,26 @@ class Timeseries { } } + // Extract and sort date/value pairs for a given subcategory and client + extractPoints(appData, endpoint, categoryName, clientName, metricField) { + const points = []; + appData?.forEach(row => { + const categoryData = row?.[endpoint]?.find(r => r.name === categoryName); + const clientData = categoryData?.[clientName]; + const y = clientData?.[metricField]; + if (y !== null && y !== undefined && y !== '') { + const num = Number(y); + if (!isNaN(num)) { + points.push({ + x: new Date(row.date).getTime(), + y: num, + }); + } + } + }); + return points.sort((a, b) => a.x - b.x); + } + // Format the data broken down by app formatDataByApp() { const series = []; @@ -516,6 +543,9 @@ class Timeseries { const urlSubcategory = UrlUtils.get(config.param); const subcategory = urlSubcategory || config.default; + const isVitals = endpoint === 'vitals' || endpoint === 'cwv'; + const hardSubcategory = isVitals ? HARD_NAV_MAP[subcategory] : null; + // Get default colors const colors = this.defaults(config)?.chart?.colors; @@ -526,30 +556,40 @@ class Timeseries { const tech = app[0]?.technology || techName; const techColor = UIUtils.getAppColor(tech, this.pageFilters.app, this.pageConfig.colors); - const validPoints = []; - app.forEach(row => { - const categoryData = row?.[endpoint]?.find(r => r.name === subcategory); - const clientData = categoryData?.[client]; - const value = clientData?.[metric]; - if (value !== null && value !== undefined && value !== '') { - const num = Number(value); - if (!isNaN(num)) { - validPoints.push({ - x: new Date(row.date).getTime(), - y: num, - }); - } - } - }); - - const sortedData = validPoints.sort((a, b) => a.x - b.x); + const sortedData = this.extractPoints(app, endpoint, subcategory, client, metric); + const seriesColor = techColor || colors[index]; series.push({ name: tech, data: sortedData, - color: techColor || colors[index], + color: seriesColor, showInLegend: sortedData.length > 0, + ...(sortedData.length === 1 && { + marker: { + enabled: true, + radius: 4, + }, + }), }); + + if (hardSubcategory) { + const sortedHardData = this.extractPoints(app, endpoint, hardSubcategory, client, metric); + if (sortedHardData.length > 0) { + series.push({ + name: `${tech} (hard navs)`, + data: sortedHardData, + color: seriesColor, + dashStyle: 'Dash', + showInLegend: true, + ...(sortedHardData.length === 1 && { + marker: { + enabled: true, + radius: 4, + }, + }), + }); + } + } }); return series; @@ -567,33 +607,16 @@ class Timeseries { const metric = this.pageConfig[this.id]?.metric; const category = this.getCategory(config); + const isVitals = endpoint === 'vitals' || endpoint === 'cwv'; + const hardCategory = isVitals ? HARD_NAV_MAP[category] : null; + // Get color scheme const theme = document.querySelector('html').dataset.theme; // Breakdown data by categories defined in config config?.series?.values?.forEach((value, index) => { - // Filter by selected client & sort const appData = this.data?.[app]; - - // Add the values for the selected metric to a new array of objects - // Formatted as coordinates for Highcharts - const formattedData = []; - appData?.forEach(row => { - const categoryData = row?.[endpoint]?.find(r => r.name === category); - const clientData = categoryData?.[value.name]; - const y = clientData?.[metric]; - if (y !== null && y !== undefined && y !== '') { - const num = Number(y); - if (!isNaN(num)) { - formattedData.push({ - x: new Date(row.date).getTime(), - y: num, - }); - } - } - }); - - const sortedData = formattedData.sort((a, b) => a.x - b.x); + const sortedData = this.extractPoints(appData, endpoint, category, value.name, metric); // Pick color from settings depending on theme const colors = this.defaults(config)?.chart?.colors; @@ -607,7 +630,32 @@ class Timeseries { data: sortedData, color: seriesColor || colors?.[index], lineWidth: 2, + ...(sortedData.length === 1 && { + marker: { + enabled: true, + radius: 4, + }, + }), }); + + if (hardCategory) { + const sortedHardData = this.extractPoints(appData, endpoint, hardCategory, value.name, metric); + if (sortedHardData.length > 0) { + series.push({ + name: `${value.name} (hard navs)`, + data: sortedHardData, + color: seriesColor || colors?.[index], + lineWidth: 2, + dashStyle: 'Dash', + ...(sortedHardData.length === 1 && { + marker: { + enabled: true, + radius: 4, + }, + }), + }); + } + } }); return series; diff --git a/src/styles/techreport/techreport.css b/src/styles/techreport/techreport.css index 045b553d..0e39b576 100644 --- a/src/styles/techreport/techreport.css +++ b/src/styles/techreport/techreport.css @@ -1056,6 +1056,11 @@ select { border-color: var(--color-text-lighter); } +.show-table:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .card.table-ui-wrapper { margin-bottom: 2rem; } diff --git a/tools/test/test_cwv_toggle.js b/tools/test/test_cwv_toggle.js index 6c348962..ce18bd08 100644 --- a/tools/test/test_cwv_toggle.js +++ b/tools/test/test_cwv_toggle.js @@ -19,23 +19,72 @@ const test_cwv_toggle = async () => { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); await new Promise((resolve) => setTimeout(resolve, 800)); - // Verify button bar exists and table wrapper was positioned after button bar + // Verify button bar exists, table wrapper positioned after bar, and histogram btn disabled (not hidden) on overall const initialCheck = await page.evaluate(() => { const bar = document.querySelector('.cwv-button-bar'); const tw = document.getElementById('good_cwv_timeseries-table-wrapper'); - if (!bar || !tw) return null; + const histBtn = document.getElementById('cwv-distribution-btn'); + if (!bar || !tw || !histBtn) return null; return { hasBar: !!bar, hasTw: !!tw, isTwAfterBar: !(bar.compareDocumentPosition(tw) & Node.DOCUMENT_POSITION_PRECEDING), + isHistBtnDisabledOnOverall: histBtn.disabled === true, + isHistBtnVisible: !histBtn.classList.contains('hidden'), }; }); if (!initialCheck || !initialCheck.isTwAfterBar) { console.error('❌ [FAIL] CWV button bar or table wrapper not properly positioned'); failures++; + } else if (!initialCheck.isHistBtnDisabledOnOverall || !initialCheck.isHistBtnVisible) { + console.error('❌ [FAIL] Histogram button should be visible and disabled for overall metric', initialCheck); + failures++; + } else { + console.log('✅ [PASS] Table wrapper positioned after .cwv-button-bar and histogram button disabled for overall'); + passes++; + } + + // Switch metric to overall_sn to verify histogram button is also disabled + const snCheck = await page.evaluate(async () => { + const select = document.querySelector('select[data-controls="good_cwv_timeseries"]'); + if (select) { + select.value = 'overall_sn'; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + await new Promise((r) => setTimeout(r, 300)); + const histBtn = document.getElementById('cwv-distribution-btn'); + return histBtn && histBtn.disabled === true && !histBtn.classList.contains('hidden'); + }); + + if (!snCheck) { + console.error('❌ [FAIL] Histogram button should be visible and disabled for overall_sn metric'); + failures++; + } else { + console.log('✅ [PASS] Histogram button disabled for overall_sn'); + passes++; + } + + // Switch metric to LCP so histogram button becomes enabled for toggle testing + await page.evaluate(() => { + const select = document.querySelector('select[data-controls="good_cwv_timeseries"]'); + if (select) { + select.value = 'LCP'; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + + const lcpCheck = await page.evaluate(() => { + const histBtn = document.getElementById('cwv-distribution-btn'); + return histBtn && histBtn.disabled === false; + }); + + if (!lcpCheck) { + console.error('❌ [FAIL] Histogram button should be enabled for LCP metric'); + failures++; } else { - console.log('✅ [PASS] Table wrapper positioned after .cwv-button-bar'); + console.log('✅ [PASS] Histogram button enabled for LCP'); passes++; }