diff --git a/libs/blocks/preflight/panels/performance.js b/libs/blocks/preflight/panels/performance.js new file mode 100644 index 0000000000..af45dc2392 --- /dev/null +++ b/libs/blocks/preflight/panels/performance.js @@ -0,0 +1,265 @@ +import { html, signal, useEffect } from '../../../deps/htm-preact.js'; +import { getMetadata } from '../../../utils/utils.js'; + +const icons = { + pass: 'green', + fail: 'red', + empty: 'empty', +}; +// TODO MEP/Personalization +// TODO mobile / tablet? +// TODO create ticket for PSI API +// TODO link to documentation directly within the sections +const text = { + lcpEl: { + key: 'lcpEl', + title: 'Valid LCP', + passed: { description: 'Valid LCP in the first section detected.' }, + failed: { description: 'No LCP image or video in the first section detected.' }, + }, + singleBlock: { + key: 'singleBlock', + title: 'Single Block', + passed: { description: 'First section has exactly one block.' }, + failed: { description: 'First section has more than one block.' }, + }, + imageSize: { + key: 'imageSize', + title: 'Images size', + empty: { description: 'No image as LCP element.' }, + passed: { description: 'LCP image is less than 100KB.' }, + failed: { description: 'LCP image is over 100KB.' }, + }, + videoPoster: { + key: 'videoPoster', + title: 'Videos', + empty: { description: 'No video as LCP element.' }, + passed: { description: 'LCP video has a poster attribute' }, + failed: { description: 'LCP video has no poster attribute.' }, + }, + fragments: { + key: 'fragments', + title: 'Fragments', + passed: { description: 'No fragments used within the LCP section.' }, + failed: { description: 'Fragments used within the LCP section.' }, + }, + personalization: { + key: 'personalization', + title: 'Personalization', + passed: { description: 'Personalization is currently not enabled.' }, + failed: { description: 'MEP or Target enabled.' }, + }, + placeholders: { + key: 'placeholders', + title: 'Placeholders', + passed: { description: 'No placeholders found within the LCP section.' }, + failed: { description: 'Placeholders found within the LCP section.' }, + }, + icons: { + key: 'icons', + title: 'Icons', + passed: { description: 'No icons found within the LCP section.' }, + failed: { description: 'Icons found within the LCP section.' }, + }, +}; + +export const config = { + items: signal([ + ...Object.values(text).map(({ key, title }) => ({ + key, + title, + icon: icons.empty, + description: 'Loading...', + })), + ]), + lcp: null, + cls: 0, +}; + +export const findItem = (key) => config.items.value.find((item) => item.key === key); +export const updateItem = ({ key, ...updates }) => { + const { items } = config; + items.value = items.value.map((item) => (item.key === key ? { ...item, ...updates } : item)); +}; + +export function conditionalItemUpdate({ emptyWhen, failsWhen, key }) { + const icon = (emptyWhen && icons.empty) || (failsWhen && icons.fail) || icons.pass; + const descriptionKey = (emptyWhen && 'empty') || (failsWhen && 'failed') || 'passed'; + updateItem({ + key, + icon, + description: text[key][descriptionKey].description, + }); +} + +// TODO do we also want to check the content-length header? +// https://www.w3.org/TR/largest-contentful-paint/#largest-contentful-paint-candidate-element +// candidate’s element is a text node, or candidate’s request's response's content length +// in bytes is >= candidate’s element's effective visual size * 0.004 +export async function checkImageSize() { + const { lcp } = config; + let hasValidImage = lcp?.url && !lcp.url.match('media_.*.mp4'); + let blob; + let isSizeValid; + if (hasValidImage) { + try { + blob = await fetch(lcp.url).then((res) => res.blob()); + isSizeValid = blob.size / 1024 <= 100; + } catch (error) { + hasValidImage = false; + } + } + + conditionalItemUpdate({ + failsWhen: !isSizeValid, + emptyWhen: !hasValidImage, + key: text.imageSize.key, + }); +} + +export function checkLCP() { + const { lcp } = config; + const firstSection = document.querySelector('main > div.section'); + const validLcp = lcp?.element && lcp?.url && firstSection?.contains(lcp.element); + conditionalItemUpdate({ + failsWhen: !validLcp, + key: text.lcpEl.key, + description: text.lcpEl.passed.description, + }); + return validLcp; +} + +export const checkFragments = () => conditionalItemUpdate({ + failsWhen: config.lcp.element.closest('.fragment') || config.lcp.element.closest('.section')?.querySelector('[data-path*="fragment"]'), + key: text.fragments.key, +}); + +export const checkPlaceholders = () => conditionalItemUpdate({ + failsWhen: config.lcp.element.closest('.section').dataset.hasPlaceholders === 'true', + key: text.placeholders.key, +}); + +export const checkForPersonalization = () => conditionalItemUpdate({ + failsWhen: getMetadata('personalization') || getMetadata('target') === 'on', + key: text.personalization.key, +}); + +export const checkVideosWithoutPosterAttribute = () => conditionalItemUpdate({ + failsWhen: !config.lcp.element.poster, + emptyWhen: !config.lcp.url.match('media_.*.mp4'), + key: text.videoPoster.key, +}); + +export const checkIcons = () => conditionalItemUpdate({ + failsWhen: config.lcp.element.closest('.section').querySelector('.icon-milo'), + key: text.icons.key, +}); + +export function PerformanceItem({ icon, title, description }) { + return html`
+
+
+

${title}

+

${description}

+
+
`; +} + +export const checkForSingleBlock = () => conditionalItemUpdate({ + failsWhen: document.querySelector('main > div.section').childElementCount > 1, + key: text.singleBlock.key, +}); + +export const createPerformanceItem = ({ + icon, + title, + description, +} = {}) => html`<${PerformanceItem} + icon=${icon} + title=${title} + description=${description} +/>`; + +let clonedLcpSection; +function highlightElement(event) { + if (!config.lcp) return; + const lcpSection = config.lcp?.element.closest('.section'); + const tooltip = document.querySelector('.lcp-tooltip-modal'); + const { offsetHeight, offsetWidth } = lcpSection; + const scaleFactor = Math.min(500 / offsetWidth, 500 / offsetHeight); + if (!clonedLcpSection) { + clonedLcpSection = lcpSection.cloneNode(true); + clonedLcpSection.classList.add('lcp-clone'); + } + Object.assign(clonedLcpSection.style, { + width: `${lcpSection.offsetWidth}px`, + height: `${lcpSection.offsetHeight}px`, + transform: `scale(${scaleFactor})`, + transformOrigin: 'top left', + }); + if (!tooltip.children.length) tooltip.appendChild(clonedLcpSection); + const { top, left } = event.currentTarget.getBoundingClientRect(); + Object.assign(tooltip.style, { + width: `${offsetWidth * scaleFactor}px`, + height: `${offsetHeight * scaleFactor}px`, + top: `${top + window.scrollY - offsetHeight * scaleFactor - 10}px`, + left: `${left + window.scrollX}px`, + }); + document.querySelector('.lcp-tooltip-modal').classList.add('show'); +} + +const removeHighlight = () => { document.querySelector('.lcp-tooltip-modal').classList.remove('show'); }; + +function observePerfMetrics() { + new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) config.lcp = lastEntry; + if (!checkLCP()) { + Object.values(text).forEach(({ key }) => { + if (key === 'lcpEl') return; + updateItem({ key, description: 'No LCP element found.' }); + }); + return; + } + checkFragments(); + checkForPersonalization(); + checkVideosWithoutPosterAttribute(); + checkIcons(); + checkForSingleBlock(); + checkPlaceholders(); + Promise.all([checkImageSize()]); + }).observe({ type: 'largest-contentful-paint', buffered: true }); + + new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + entries.forEach((entry) => { + if (!entry.hadRecentInput) { + config.cls += entry.value; + } + }); + if (config.cls > 0) { + // TODO - Lana log? We should not have any CLS. + } + }).observe({ type: 'layout-shift', buffered: true }); +} + +export function Panel() { + useEffect(() => observePerfMetrics(), []); + + return html` +
+
${config.items.value.slice(0, 4).map((item) => createPerformanceItem(item))}
+
${config.items.value.slice(4, 8).map((item) => createPerformanceItem(item))}
+
Unsure on how to get this page fully into the green? Check out the Milo Performance Guidelines.
+
+ + Highlight the found LCP section + +
+
+
+ `; +} + +export default Panel; diff --git a/libs/blocks/preflight/panels/seo.js b/libs/blocks/preflight/panels/seo.js index fe1f704d78..f240762218 100644 --- a/libs/blocks/preflight/panels/seo.js +++ b/libs/blocks/preflight/panels/seo.js @@ -292,11 +292,11 @@ export async function sendResults() { function SeoItem({ icon, title, description }) { return html` -
+
-
-

${title}

-

${description}

+
+

${title}

+

${description}

`; } @@ -327,14 +327,14 @@ async function getResults() { export default function Panel() { useEffect(() => { getResults(); }, []); return html` -
-
+
+
<${SeoItem} icon=${titleResult.value.icon} title=${titleResult.value.title} description=${titleResult.value.description} /> <${SeoItem} icon=${h1Result.value.icon} title=${h1Result.value.title} description=${h1Result.value.description} /> <${SeoItem} icon=${canonResult.value.icon} title=${canonResult.value.title} description=${canonResult.value.description} /> <${SeoItem} icon=${linksResult.value.icon} title=${linksResult.value.title} description=${linksResult.value.description} />
-
+
<${SeoItem} icon=${bodyResult.value.icon} title=${bodyResult.value.title} description=${bodyResult.value.description} /> <${SeoItem} icon=${loremResult.value.icon} title=${loremResult.value.title} description=${loremResult.value.description} /> <${SeoItem} icon=${descResult.value.icon} title=${descResult.value.title} description=${descResult.value.description} /> diff --git a/libs/blocks/preflight/preflight.css b/libs/blocks/preflight/preflight.css index 73a31fab35..5c2c2f7e78 100644 --- a/libs/blocks/preflight/preflight.css +++ b/libs/blocks/preflight/preflight.css @@ -312,21 +312,21 @@ span.preflight-time { } /* SEO */ -.seo-columns { +.preflight-columns { margin: 24px 48px 0; display: grid; grid-template-columns: 1fr 1fr; gap: 48px; } -.seo-item { +.preflight-item { margin-bottom: 48px; display: grid; grid-template-columns: auto 1fr; gap: 12px; } -.seo-item:last-child { +.preflight-item:last-child { margin-bottom: 0; } @@ -341,6 +341,11 @@ span.preflight-time { animation: spin 2s linear infinite; } +.result-icon.empty { + background: url('./img/empty.svg'); + background-size: 60px; +} + @keyframes spin { 100% { -webkit-transform: rotate(360deg); @@ -367,14 +372,14 @@ span.preflight-time { background-size: 60px; } -.seo-item-title { +.preflight-item-title { margin: 0; font-weight: 700; font-size: 32px; line-height: 1; } -.seo-item-description { +.preflight-item-description { margin: 0; } @@ -625,4 +630,38 @@ img[data-alt-check]::after { .dialog-modal#preflight table td h3 { font-size: 16px; -} \ No newline at end of file +} + +.performance-guidelines { + color: #fff; + text-decoration: underline; +} + +.performance-element-preview { + position: relative; + display: inline-block; + color: #fff; + text-decoration: underline; +} + +.performance-element-preview:hover { + text-decoration: none; +} + +/* styles.css */ +.lcp-tooltip-modal { + width: 0; + height: 0; + position: fixed; + border: 2px solid red; + background-color: white; + z-index: 103; + overflow: hidden; + pointer-events: none; + display: block; + visibility: hidden; +} + +.lcp-tooltip-modal.show { + visibility: visible; +} diff --git a/libs/blocks/preflight/preflight.js b/libs/blocks/preflight/preflight.js index 9ec37f5c77..50456fe78e 100644 --- a/libs/blocks/preflight/preflight.js +++ b/libs/blocks/preflight/preflight.js @@ -4,6 +4,7 @@ import General from './panels/general.js'; import SEO from './panels/seo.js'; import Accessibility from './panels/accessibility.js'; import Martech from './panels/martech.js'; +import Performance from './panels/performance.js'; const HEADING = 'Milo Preflight'; const IMG_PATH = '/blocks/preflight/img'; @@ -13,6 +14,7 @@ const tabs = signal([ { title: 'SEO' }, { title: 'Martech' }, { title: 'Accessibility' }, + { title: 'Performance' }, ]); function setTab(active) { @@ -32,6 +34,8 @@ function setPanel(title) { return html`<${Martech} />`; case 'Accessibility': return html`<${Accessibility} />`; + case 'Performance': + return html`<${Performance} />`; default: return html`

No matching panel.

`; } diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 2d3de31d73..930ade94c6 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -813,6 +813,7 @@ async function decoratePlaceholders(area, config) { if (!area) return; const nodes = findReplaceableNodes(area); if (!nodes.length) return; + area.dataset.hasPlaceholders = 'true'; const placeholderPath = `${config.locale?.contentRoot}/placeholders.json`; placeholderRequest = placeholderRequest || customFetch({ resource: placeholderPath, withCacheRules: true }) diff --git a/test/blocks/preflight/panels/performance/mocks/marquee.html b/test/blocks/preflight/panels/performance/mocks/marquee.html new file mode 100644 index 0000000000..3646e2aa56 --- /dev/null +++ b/test/blocks/preflight/panels/performance/mocks/marquee.html @@ -0,0 +1,30 @@ +
+
+
+
+
+
+ + + + + + +
+
+
+
+

Using data to transform attitudes and evolve experiences.

+

How BMW Group transformed the online automotive experience for customers, dealers, and employees

+

+ + + Watch Now + + +

+
+
+
+
+
diff --git a/test/blocks/preflight/panels/performance/performance.test.js b/test/blocks/preflight/panels/performance/performance.test.js new file mode 100644 index 0000000000..cb5bf2d9f7 --- /dev/null +++ b/test/blocks/preflight/panels/performance/performance.test.js @@ -0,0 +1,281 @@ +/* eslint-disable import/no-named-as-default-member */ +import { expect } from 'chai'; +import { html, render } from '../../../../../libs/deps/htm-preact.js'; +import { mockFetch } from '../../../../helpers/generalHelpers.js'; +import { + config, + updateItem, + checkImageSize, + findItem, + checkLCP, + checkFragments, + checkPlaceholders, + checkForPersonalization, + conditionalItemUpdate, + checkVideosWithoutPosterAttribute, + checkForSingleBlock, + PerformanceItem, + createPerformanceItem, + Panel, +} from '../../../../../libs/blocks/preflight/panels/performance.js'; + +const icons = { + pass: 'green', + fail: 'red', + empty: 'empty', +}; +const defaultItems = [...config.items.value]; +describe('Preflight performance', () => { + const { fetch } = window; + + afterEach(() => { + document.body.innerHTML = ''; + document.head.innerHTML = ''; + window.fetch = fetch; + config.lcp = null; + config.cls = 0; + config.items.value = defaultItems; + }); + + describe('updateItem', () => { + it('updates an item', () => { + const item = { key: 'lcpEl', description: 'foo-desc', icon: 'foo-icon', title: 'foo-title' }; + updateItem(item); + Object.keys(item).forEach((key) => expect(findItem('lcpEl')[key]).to.equal(item[key])); + const updatedItem = { key: 'lcpEl', description: 'foo-desc-update', icon: 'foo-icon-update', title: 'foo-title-update' }; + updateItem(updatedItem); + Object.keys(updatedItem).forEach((key) => expect(findItem('lcpEl')[key]).to.equal(updatedItem[key])); + }); + + it('updating keeps unrelated items unchanged', () => { + const unchangedItem = { key: 'unchangedItem', description: 'foo-desc', icon: 'foo-icon', title: 'foo-title' }; + const baseItem = { key: 'lcpEl', description: 'foo-desc' }; + config.items.value = [ + unchangedItem, + baseItem, + ]; + updateItem({ ...baseItem, description: 'foo-desc-update' }); + Object.keys(unchangedItem).forEach((key) => expect(findItem('unchangedItem')[key]).to.equal(unchangedItem[key])); + }); + + it('does not update non-existant items', () => { + config.items.value = [{ key: 'lcpEl' }]; + updateItem({ key: 'new-item', description: 'new-desc' }); + expect(config.items.value.length).to.equal(1); + expect(config.items.value[0].key).to.equal('lcpEl'); + }); + }); + + describe('findItem', () => { + it('finds an item', () => { + const item = { key: 'lcpEl', description: 'foo-desc', icon: 'foo-icon', title: 'foo-title' }; + config.items.value = [item]; + Object.keys(item).forEach((key) => expect(findItem('lcpEl')[key]).to.equal(item[key])); + }); + + it('returns undefined if the item does not exist', () => { + config.items.value = []; + expect(findItem('lcpEl')).to.be.undefined; + }); + }); + + describe('conditionalItemUpdate', () => { + it('updates an item, if the condition isnt met', () => { + updateItem({ key: 'lcpEl', icon: icons.empty }); + conditionalItemUpdate({ failsWhen: false, key: 'lcpEl' }); + expect(findItem('lcpEl').icon).to.equal(icons.pass); + }); + + it('does not update an item if the condition is met', () => { + updateItem({ key: 'lcpEl', icon: icons.empty }); + conditionalItemUpdate({ failsWhen: true, key: 'lcpEl' }); + expect(findItem('lcpEl').icon).to.equal(icons.fail); + }); + }); + + describe('Check image sizes', () => { + it('shows an empty field if there is no LCP image', async () => { + await checkImageSize(); + expect(findItem('imageSize').icon).to.equal(icons.empty); + }); + + it('Checks if an image is below 100 kb', async () => { + config.lcp = { url: 'https://adobe.com/foo.jpg' }; + window.fetch = mockFetch({ payload: { size: 200 } }); + await checkImageSize(); + expect(findItem('imageSize').icon).to.equal(icons.pass); + }); + + it('Checks if an image is >100 kb', async () => { + config.lcp = { url: 'https://example.com/foo.jpg' }; + window.fetch = mockFetch({ payload: { size: 200000 } }); + await checkImageSize(); + expect(findItem('imageSize').icon).to.equal(icons.fail); + }); + }); + + describe('Check if there is an LCP element', () => { + it('fails if there is no LCP element', async () => { + await checkLCP(); + expect(findItem('lcpEl').icon).to.equal(icons.fail); + }); + + it('fails if the LCP element is in the second section', async () => { + document.body.innerHTML = '
'; + config.lcp = { + element: document.querySelector('img'), + url: 'https://adobe.com/foo.jpg', + }; + await checkLCP(); + expect(findItem('lcpEl').icon).to.equal(icons.fail); + }); + + it('succeeds if the LCP element is in the first section', async () => { + document.body.innerHTML = '
'; + config.lcp = { + element: document.querySelector('img'), + url: 'https://adobe.com/foo.jpg', + }; + await checkLCP(); + expect(findItem('lcpEl').icon).to.equal(icons.pass); + }); + }); + + describe('Checking for fragments', () => { + it('fails if there are fragments', async () => { + document.body.innerHTML = '
'; + config.lcp = { element: document.querySelector('img') }; + checkFragments(); + expect(findItem('fragments').icon).to.equal(icons.fail); + }); + + it('fails if there (inline) are fragments', async () => { + document.body.innerHTML = '
'; + config.lcp = { element: document.querySelector('img') }; + checkFragments(); + expect(findItem('fragments').icon).to.equal(icons.fail); + }); + + it('succeeds if there are no fragments', async () => { + document.body.innerHTML = '
'; + config.lcp = { element: document.querySelector('img') }; + checkFragments(); + expect(findItem('fragments').icon).to.equal(icons.pass); + }); + }); + + describe('Checking for placeholders', () => { + it('succeeds if there are no placeholders', async () => { + document.body.innerHTML = '
'; + config.lcp = { element: document.querySelector('.section') }; + checkPlaceholders(); + expect(findItem('placeholders').icon).to.equal(icons.pass); + }); + + it('fails if there are placeholders', async () => { + document.body.innerHTML = '
'; + config.lcp = { element: document.querySelector('.section') }; + checkPlaceholders(); + expect(findItem('placeholders').icon).to.equal(icons.fail); + }); + }); + + describe('Checking for personalization', () => { + it('fails if there is personalization', async () => { + document.head.innerHTML = ''; + checkForPersonalization(); + expect(findItem('personalization').icon).to.equal(icons.fail); + }); + + it('fails if there is target', async () => { + document.head.innerHTML = ''; + checkForPersonalization(); + expect(findItem('personalization').icon).to.equal(icons.fail); + }); + + it('succeeds if there is no personalization', async () => { + document.head.innerHTML = ''; + checkForPersonalization(); + expect(findItem('personalization').icon).to.equal(icons.pass); + }); + }); + + describe('Check for video without poster attribute', () => { + it('stays empty if there are no videos', async () => { + document.body.innerHTML = '
'; + config.lcp = { + element: document.querySelector('img'), + url: 'https://adobe.com/media_.png', + }; + checkVideosWithoutPosterAttribute(); + expect(findItem('videoPoster').icon).to.equal(icons.empty); + }); + + it('fails if there are videos without poster attribute', async () => { + document.body.innerHTML = '
'; + config.lcp = { + element: document.querySelector('video'), + url: 'https://adobe.com/media_.mp4', + }; + checkVideosWithoutPosterAttribute(); + expect(findItem('videoPoster').icon).to.equal(icons.fail); + }); + + it('succeeds if there are videos with poster attribute', async () => { + document.body.innerHTML = '
'; + config.lcp = { + element: document.querySelector('video'), + url: 'https://adobe.com/media_.mp4', + }; + checkVideosWithoutPosterAttribute(); + expect(findItem('videoPoster').icon).to.equal(icons.pass); + }); + }); + + describe('Check for multiple elements in the first section', () => { + it('fails if there are multiple elements', async () => { + document.body.innerHTML = '
'; + checkForSingleBlock(); + expect(findItem('singleBlock').icon).to.equal(icons.fail); + }); + + it('succeeds if there is only one element', async () => { + document.body.innerHTML = '
'; + checkForSingleBlock(); + expect(findItem('singleBlock').icon).to.equal(icons.pass); + }); + }); + + describe('PerformanceItem', () => { + it('renders an item', () => { + const item = html`<${PerformanceItem} icon="foo-icon" title="foo-title" description="foo-desc" />`; + render(item, document.body); + const itemElement = document.querySelector('.preflight-item'); + expect(itemElement).to.exist; + expect(itemElement.querySelector('.result-icon').classList.contains('foo-icon')).to.be.true; + expect(itemElement.querySelector('.preflight-item-title').textContent).to.equal('foo-title'); + expect(itemElement.querySelector('.preflight-item-description').textContent).to.equal('foo-desc'); + }); + }); + + describe('createPerformanceItem', () => { + it('creates an item', () => { + const item = createPerformanceItem({ icon: 'foo-icon', title: 'foo-title', description: 'foo-desc' }); + render(item, document.body); + const itemElement = document.querySelector('.preflight-item'); + expect(itemElement).to.exist; + expect(itemElement.querySelector('.result-icon').classList.contains('foo-icon')).to.be.true; + expect(itemElement.querySelector('.preflight-item-title').textContent).to.equal('foo-title'); + expect(itemElement.querySelector('.preflight-item-description').textContent).to.equal('foo-desc'); + }); + }); + + describe('Panel', () => { + it('renders a panel with all the items', () => { + const panel = html`<${Panel} />`; + render(panel, document.body); + const panelItems = document.querySelectorAll('.preflight-item'); + expect(panelItems.length).to.equal(config.items.value.length); + }); + }); +}); diff --git a/test/helpers/generalHelpers.js b/test/helpers/generalHelpers.js index 23bb5b33a9..90fdeaff62 100644 --- a/test/helpers/generalHelpers.js +++ b/test/helpers/generalHelpers.js @@ -6,6 +6,7 @@ export const mockRes = ({ payload, status = 200, ok = true } = {}) => new Promis ok, json: () => payload, text: () => payload, + blob: () => payload, }); }); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 8e67cd1833..1c0d803c6e 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -99,7 +99,7 @@ export default { `, - // Comment in the files for selectively running test suites - // npm run test:file:watch allows to you to run single test file & view the result in a browser. + // npm run test:file:watch + // allows to you to run single test file & view the result in a browser. // files: ['**/utils.test.js'], };