<${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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {