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++;
}