diff --git a/src/BeaconManager.js b/src/BeaconManager.js index c926d70..431ff4e 100644 --- a/src/BeaconManager.js +++ b/src/BeaconManager.js @@ -2,6 +2,7 @@ import BeaconLcp from "./BeaconLcp.js"; import BeaconLrc from "./BeaconLrc.js"; +import BeaconPreloadFonts from "./BeaconPreloadFonts.js"; import BeaconUtils from "./Utils.js"; import Logger from "./Logger.js"; @@ -10,6 +11,7 @@ class BeaconManager { this.config = config; this.lcpBeacon = null; this.lrcBeacon = null; + this.preloadFontsBeacon = null; this.infiniteLoopId = null; this.errorCode = ''; this.logger = new Logger(this.config.debug); @@ -34,13 +36,16 @@ class BeaconManager { const isGeneratedBefore = await this._getGeneratedBefore(); - // OCI / LCP / ATF + // OCI / LCP / ATF / PRELOAD FONTS const shouldGenerateLcp = ( this.config.status.atf && (isGeneratedBefore === false || isGeneratedBefore.lcp === false) ); const shouldGeneratelrc = ( this.config.status.lrc && (isGeneratedBefore === false || isGeneratedBefore.lrc === false) ); + const shouldGeneratePreloadFonts = ( + this.config.status.preload_fonts && (isGeneratedBefore === false || isGeneratedBefore.preload_fonts === false) + ); if (shouldGenerateLcp) { this.lcpBeacon = new BeaconLcp(this.config, this.logger); await this.lcpBeacon.run(); @@ -55,7 +60,14 @@ class BeaconManager { this.logger.logMessage('Not running BeaconLrc because data is already available or feature is disabled'); } - if (shouldGenerateLcp || shouldGeneratelrc) { + if (shouldGeneratePreloadFonts) { + this.preloadFontsBeacon = new BeaconPreloadFonts(this.config, this.logger); + await this.preloadFontsBeacon.run(); + } else { + this.logger.logMessage('Not running BeaconPreloadFonts because data is already available or feature is disabled'); + } + + if (shouldGenerateLcp || shouldGeneratelrc || shouldGeneratePreloadFonts) { this._saveFinalResultIntoDB(); } else { this.logger.logMessage("Not saving results into DB as no beacon features ran."); @@ -101,7 +113,8 @@ class BeaconManager { _saveFinalResultIntoDB() { const results = { lcp: this.lcpBeacon ? this.lcpBeacon.getResults() : null, - lrc: this.lrcBeacon ? this.lrcBeacon.getResults() : null + lrc: this.lrcBeacon ? this.lrcBeacon.getResults() : null, + preload_fonts: this.preloadFontsBeacon ? this.preloadFontsBeacon.getResults() : null }; const data = new FormData(); diff --git a/src/BeaconPreloadFonts.js b/src/BeaconPreloadFonts.js new file mode 100644 index 0000000..9ff4745 --- /dev/null +++ b/src/BeaconPreloadFonts.js @@ -0,0 +1,511 @@ +'use strict'; +class BeaconPreloadFonts { + constructor(config, logger) { + this.config = config; + this.logger = logger; + this.aboveTheFoldFonts = []; + } + + static FONT_FILE_REGEX = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + + /** + * Checks if a given font family is a system font. + * + * This method checks if the provided font family is part of the system fonts + * defined in the configuration. It returns true if the font family is a system + * font, and false otherwise. + * + * @param {string} fontFamily - The font family to check. + * @returns {boolean} True if the font family is a system font, false otherwise. + */ + isSystemFont(fontFamily) { + const systemFonts = new Set(this.config.system_fonts); + return systemFonts.has(fontFamily); + } + + /** + * Checks if an element is visible in the viewport. + * + * This method checks if the provided element is visible in the viewport by + * considering its display, visibility, opacity, width, and height properties. + * It returns true if the element is visible, and false otherwise. + * + * @param {Element} element - The element to check for visibility. + * @returns {boolean} True if the element is visible, false otherwise. + */ + isElementVisible(element) { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return !( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + rect.width === 0 || + rect.height === 0 + ); + } + + /** + * Cleans a URL by removing query parameters and fragments. + * + * This method takes a URL as input, removes any query parameters and fragments, + * and returns the cleaned URL. + * + * @param {string} url - The URL to clean. + * @returns {string} The cleaned URL. + */ + cleanUrl(url) { + try { + url = url.split('?')[0].split('#')[0]; + return new URL(url, window.location.href).href; + } catch (e) { + return url; + } + } + + /** + * Retrieves a map of network-loaded fonts. + * + * This method uses the Performance API to get all resource entries, filters out + * the ones that match the font file regex, and maps them to their cleaned URLs. + * + * @returns {Map} A map where each key is a cleaned URL of a font file and + * each value is the original URL of the font file. + */ + getNetworkLoadedFonts() { + return new Map( + window.performance + .getEntriesByType('resource') + .filter((resource) => BeaconPreloadFonts.FONT_FILE_REGEX.test(resource.name)) + .map((resource) => [this.cleanUrl(resource.name), resource.name]) + ); + } + + /** + * Retrieves font-face rules from stylesheets. + * + * This method scans all stylesheets loaded on the page and collects + * font-face rules, including their source URLs, font families, weights, + * and styles. It returns an object containing the collected font data. + * + * @returns {Object} An object mapping font families to their respective + * URLs and variations. + */ + getFontFaceRules() { + const stylesheetFonts = {}; + + Array.from(document.styleSheets).forEach((sheet) => { + // Check if the stylesheet is from the same domain + if (sheet.href && new URL(sheet.href).origin !== window.location.origin) return; + try { + Array.from(sheet.cssRules || []).forEach((rule) => { + if (rule instanceof CSSFontFaceRule) { + const src = rule.style.getPropertyValue('src'); + const fontFamily = rule.style.getPropertyValue('font-family') + .replace(/['"]+/g, '') + .trim(); + const weight = rule.style.getPropertyValue('font-weight') || '400'; + const style = rule.style.getPropertyValue('font-style') || 'normal'; + + if (!stylesheetFonts[fontFamily]) { + stylesheetFonts[fontFamily] = { + urls: [], + variations: new Set() + }; + } + + const urls = src.match(/url\(['"]?([^'"]+)['"]?\)/g) || []; + urls.forEach((urlMatch) => { + const rawUrl = urlMatch.match(/url\(['"]?([^'"]+)['"]?\)/)[1]; + // Reconstruct url to absolute + const url = new URL(rawUrl, sheet.href).href; + const normalizedUrl = this.cleanUrl(url); + if (!stylesheetFonts[fontFamily].urls.includes(normalizedUrl)) { + stylesheetFonts[fontFamily].urls.push(normalizedUrl); + stylesheetFonts[fontFamily].variations.add(JSON.stringify({ + weight, + style + })); + } + }); + } + }); + } catch (e) { + this.logger.logMessage(e); + } + }); + + Object.values(stylesheetFonts).forEach(fontData => { + fontData.variations = Array.from(fontData.variations).map(v => JSON.parse(v)); + }); + + return stylesheetFonts; + } + + /** + * Checks if an element is above the fold (visible in the viewport without scrolling). + * + * @param {Element} element - The element to check. + * @returns {boolean} True if the element is above the fold, false otherwise. + */ + isElementAboveFold(element) { + if (!this.isElementVisible(element)) return false; + + const rect = element.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const elementTop = rect.top + scrollTop; + const foldPosition = window.innerHeight || document.documentElement.clientHeight; + + return elementTop <= foldPosition; + } + + /** + * Initiates the process of analyzing and summarizing font usage on the page. + * This method fetches network-loaded fonts, stylesheet fonts, and external font pairs. + * It then processes each element on the page to determine which fonts are used above the fold. + * The results are summarized and logged. + * + * @returns {Promise} A promise that resolves when the analysis is complete. + */ + async run() { + const networkLoadedFonts = this.getNetworkLoadedFonts(); + const stylesheetFonts = this.getFontFaceRules(); + const hostedFonts = new Map(); + const externalFontPairs = this.config.font_data; + const externalFontsResults = await this.processExternalFonts(externalFontPairs); + + const elements = Array.from(document.getElementsByTagName('*')) + .filter(el => this.isElementAboveFold(el)); + + elements.forEach(element => { + const processElementFont = (style, pseudoElement = null) => { + if (!style || !this.isElementVisible(element)) return; + + const fontFamily = style.fontFamily.split(',')[0].replace(/['"]+/g, '').trim(); + const hasContent = pseudoElement ? + style.content !== 'none' && style.content !== '""' : + element.textContent.trim(); + + if (hasContent && !this.isSystemFont(fontFamily) && stylesheetFonts[fontFamily]) { + if (!hostedFonts.has(fontFamily)) { + hostedFonts.set(fontFamily, { + elements: new Set(), + urls: stylesheetFonts[fontFamily].urls, + variations: stylesheetFonts[fontFamily].variations + }); + } + hostedFonts.get(fontFamily).elements.add(element); + } + }; + + try { + processElementFont(window.getComputedStyle(element)); + ['::before', '::after'].forEach(pseudo => { + processElementFont(window.getComputedStyle(element, pseudo), pseudo); + }); + } catch (e) { + console.debug('Error processing element:', e); + } + }); + + const aboveTheFoldFonts = this.summarizeMatches(externalFontsResults, hostedFonts, networkLoadedFonts); + + // Check if allFonts, externalFonts, and hostedFonts are empty + if (!Object.keys(aboveTheFoldFonts.allFonts).length && + !Object.keys(aboveTheFoldFonts.externalFonts).length && + !Object.keys(aboveTheFoldFonts.hostedFonts).length) { + this.logger.logMessage('No fonts found above the fold.'); + return; + } + + this.logger.logMessage('Above the fold fonts:', aboveTheFoldFonts); + this.aboveTheFoldFonts = Object.values(aboveTheFoldFonts.allFonts).flatMap(font => font.variations.map(variation => variation.url)); + } + + /** + * Summarizes all font matches found on the page + * Creates a comprehensive object containing font usage data + * + * @param {Object} externalFontsResults - Results from External Fonts analysis + * @param {Map} hostedFonts - Map of hosted (non-External) fonts found + * @param {Map} networkLoadedFonts - Map of all font files loaded via network + * @returns {Object} Complete analysis of font usage including locations and counts + */ + summarizeMatches(externalFontsResults, hostedFonts, networkLoadedFonts) { + const allFonts = {}; + const hostedFontsResults = {}; + + // Process Regular Fonts first + if (hostedFonts.size > 0) { + hostedFonts.forEach((data, fontFamily) => { + if (data.variations) { + // Calculate elements once for the font family + const elements = Array.from(data.elements); + const aboveElements = elements.filter(el => this.isElementAboveFold(el)); + const belowElements = elements.filter(el => !this.isElementAboveFold(el)); + + if (!allFonts[fontFamily]) { + allFonts[fontFamily] = { + type: 'hosted', + variations: [], + elementCount: { + aboveFold: aboveElements.length, + belowFold: belowElements.length, + total: elements.length + }, + urlCount: { + aboveFold: new Set(), + belowFold: new Set() + } + }; + } + + data.variations.forEach(variation => { + let matchingUrl = null; + for (const styleUrl of data.urls) { + const normalizedStyleUrl = this.cleanUrl(styleUrl); + if (networkLoadedFonts.has(normalizedStyleUrl)) { + matchingUrl = networkLoadedFonts.get(normalizedStyleUrl); + break; + } + } + + // Add variation with correct element counts + allFonts[fontFamily].variations.push({ + weight: variation.weight, + style: variation.style, + url: matchingUrl || 'File not found', + elementCount: { + aboveFold: aboveElements.length, + belowFold: belowElements.length, + total: elements.length + } + }); + + // Track URLs per location + if (matchingUrl) { + if (aboveElements.length > 0) { + allFonts[fontFamily].urlCount.aboveFold.add(matchingUrl); + } + if (belowElements.length > 0) { + allFonts[fontFamily].urlCount.belowFold.add(matchingUrl); + } + } + }); + + if (!Object.prototype.hasOwnProperty.call(allFonts, fontFamily)) { + return; + } + + // Copy to hostedFontsResults + hostedFontsResults[fontFamily] = { + variations: allFonts[fontFamily].variations, + elementCount: { ...allFonts[fontFamily].elementCount }, + urlCount: { ...allFonts[fontFamily].urlCount }, + }; + } + }); + } + + // Process External Fonts + if (Object.keys(externalFontsResults).length > 0) { + Object.entries(externalFontsResults).forEach(([url, data]) => { + // Only process if we're in full mode or the font appears above fold + if (data.elementCount.aboveFold > 0) { + data.variations.forEach(variation => { + // Initialize font family entry if it doesn't exist + if (!allFonts[variation.family]) { + allFonts[variation.family] = { + type: 'external', + variations: [], + // Track element counts at font family level + elementCount: { + aboveFold: 0, + belowFold: 0, + total: 0 + }, + // Track unique URLs used in each fold location + urlCount: { + aboveFold: new Set(), + belowFold: new Set() + } + }; + } + + // Split elements into above and below fold for accurate counting + const aboveElements = Array.from(data.elements).filter(el => this.isElementAboveFold(el)); + const belowElements = Array.from(data.elements).filter(el => !this.isElementAboveFold(el)); + + // Add variation with its specific location and element counts + allFonts[variation.family].variations.push({ + weight: variation.weight, + style: variation.style, + url: url, + elementCount: { + aboveFold: aboveElements.length, + belowFold: belowElements.length, + total: data.elements.length + } + }); + + // Update font family level counts + allFonts[variation.family].elementCount.aboveFold += aboveElements.length; + allFonts[variation.family].elementCount.belowFold += belowElements.length; + allFonts[variation.family].elementCount.total += data.elements.length; + + // Track unique URLs per location at font family level + if (aboveElements.length > 0) { + allFonts[variation.family].urlCount.aboveFold.add(url); + } + if (belowElements.length > 0) { + allFonts[variation.family].urlCount.belowFold.add(url); + } + }); + } + }); + } + + // Convert URL count Sets to numbers + Object.values(allFonts).forEach(font => { + font.urlCount = { + aboveFold: font.urlCount.aboveFold.size, + belowFold: font.urlCount.belowFold.size, + total: new Set([...font.urlCount.aboveFold, ...font.urlCount.belowFold]).size + }; + }); + + // Also convert URL count Sets in hostedFontsResults + Object.values(hostedFontsResults).forEach(font => { + if (font.urlCount.aboveFold instanceof Set) { + font.urlCount = { + aboveFold: font.urlCount.aboveFold.size, + belowFold: font.urlCount.belowFold.size, + total: new Set([...font.urlCount.aboveFold, ...font.urlCount.belowFold]).size + }; + } + }); + + return { + externalFonts: Object.fromEntries( + Object.entries(externalFontsResults).filter( + (entry) => entry[1].elementCount.aboveFold > 0 + ) + ), + hostedFonts: hostedFontsResults, + allFonts + }; + } + + /** + * Processes external font pairs to identify their usage on the page. + * + * This method iterates through all elements on the page, checks if they are above the fold, + * and determines the font information for each element. It then matches the font information + * with the provided external font pairs to identify which fonts are used and where. + * + * @param {Object} fontPairs - An object where each key is a URL and the value is an array of font variations. + * @returns {Promise} A promise that resolves to an object where each key is a URL and the value is an object containing information about the elements using that font. + */ + async processExternalFonts(fontPairs) { + const matches = new Map(); + const elements = Array.from(document.getElementsByTagName('*')) + .filter(el => this.isElementAboveFold(el)); + + const fontMap = new Map(); + Object.entries(fontPairs).forEach(([url, variations]) => { + variations.forEach(variation => { + const key = `${variation.family}|${variation.weight}|${variation.style}`; + fontMap.set(key, { url, ...variation }); + }); + }); + + const getFontInfoForElement = (style) => { + const family = style.fontFamily + .split(',')[0] + .replace(/['"]+/g, '') + .trim(); + const weight = style.fontWeight; + const fontStyle = style.fontStyle; + const key = `${family}|${weight}|${fontStyle}`; + + // Check if the requested font variation exists in fontMap + let fontInfo = fontMap.get(key); + + // If the font variation does not exist, check for fallback weight (400) + if (!fontInfo && weight !== '400') { + const fallbackKey = `${family}|400|${fontStyle}`; + fontInfo = fontMap.get(fallbackKey); + } + + return fontInfo; + }; + + elements.forEach(element => { + if (element.textContent.trim()) { + const style = window.getComputedStyle(element); + const fontInfo = getFontInfoForElement(style); + if (fontInfo) { + if (!matches.has(fontInfo.url)) { + matches.set(fontInfo.url, { + elements: new Set(), + variations: new Set() + }); + } + matches.get(fontInfo.url).elements.add(element); + matches.get(fontInfo.url).variations.add(JSON.stringify({ + family: fontInfo.family, + weight: fontInfo.weight, + style: fontInfo.style + })); + } + } + + ['::before', '::after'].forEach(pseudo => { + const pseudoStyle = window.getComputedStyle(element, pseudo); + if (pseudoStyle.content !== 'none' && pseudoStyle.content !== '""') { + const fontInfo = getFontInfoForElement(pseudoStyle); + if (fontInfo) { + if (!matches.has(fontInfo.url)) { + matches.set(fontInfo.url, { + elements: new Set(), + variations: new Set() + }); + } + matches.get(fontInfo.url).elements.add(element); + matches.get(fontInfo.url).variations.add(JSON.stringify({ + family: fontInfo.family, + weight: fontInfo.weight, + style: fontInfo.style + })); + } + } + }); + }); + + return Object.fromEntries( + Array.from(matches.entries()).map(([url, data]) => [ + url, + { + elementCount: { + aboveFold: Array.from(data.elements).filter(el => this.isElementAboveFold(el)).length, + total: data.elements.size + }, + variations: Array.from(data.variations).map(v => JSON.parse(v)), + elements: Array.from(data.elements) + } + ]) + ); + } + + /** + * Retrieves the results of the font analysis, specifically the fonts used above the fold. + * This method returns an array containing the URLs of the fonts used above the fold. + * + * @returns {Array} An array of URLs of the fonts used above the fold. + */ + getResults() { + return this.aboveTheFoldFonts; + } +} + +export default BeaconPreloadFonts; \ No newline at end of file diff --git a/src/Logger.js b/src/Logger.js index 86010b5..917155f 100644 --- a/src/Logger.js +++ b/src/Logger.js @@ -5,12 +5,18 @@ class Logger { this.enabled = enabled; } - logMessage(msg) { + logMessage(label, msg = '') { if (!this.enabled) { - return; + return; } - console.log(msg); - } + + if (msg !== '') { + console.log(label, msg); + return; + } + + console.log(label); + } logColoredMessage( msg, color = 'green' ) { if (!this.enabled) { diff --git a/test/BeaconManager.test.js b/test/BeaconManager.test.js index e9bb29b..e4da812 100644 --- a/test/BeaconManager.test.js +++ b/test/BeaconManager.test.js @@ -411,7 +411,7 @@ describe('BeaconManager', function() { assert.strictEqual(sentDataObject['url'], config.url); assert.strictEqual(sentDataObject['is_mobile'], config.is_mobile.toString()); // For complex types like arrays or objects, you might need to parse them before assertion - const expectedResults = JSON.parse(JSON.stringify({lcp : beacon.lcpBeacon.performanceImages, lrc: null})); + const expectedResults = JSON.parse(JSON.stringify({lcp : beacon.lcpBeacon.performanceImages, lrc: null, preload_fonts: null})); assert.deepStrictEqual(JSON.parse(sentDataObject['results']), expectedResults); assert.strictEqual(sentDataObject['status'], beacon._getFinalStatus()); sinon.assert.calledOnce(finalizeSpy); diff --git a/test/BeaconPreloadFonts.test.js b/test/BeaconPreloadFonts.test.js new file mode 100644 index 0000000..26d5743 --- /dev/null +++ b/test/BeaconPreloadFonts.test.js @@ -0,0 +1,292 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import BeaconPreloadFonts from '../src/BeaconPreloadFonts.js'; + +describe('BeaconPreloadFonts', () => { + let beaconPreloadFonts; + let loggerMock; + + beforeEach(() => { + loggerMock = { + logMessage: sinon.spy() + }; + const config = { + system_fonts: ['Arial', 'Helvetica'], + font_data: {} + }; + + // Initialize the class with mock config and logger + beaconPreloadFonts = new BeaconPreloadFonts(config, loggerMock); + + // Mocking the document object + global.document = { + createElement: (tagName) => { + return { + tagName, + style: {}, + getBoundingClientRect: () => ({ top: 0, height: 100, width: 100 }), + appendChild: function() {}, + removeChild: function() {} + }; + }, + querySelectorAll: () => { + return []; // Return an empty array for any selector + }, + body: { + appendChild: function() {}, + removeChild: function() {} + }, + getElementsByTagName: sinon.stub().returns([ + { + style: { fontFamily: 'CustomFont' }, + textContent: 'Test Content', + getBoundingClientRect: () => ({ top: 0, height: 100, width: 100 }) // Mocking getBoundingClientRect + }, + { + style: { fontFamily: 'SystemFont' }, + textContent: 'System Font Content', + getBoundingClientRect: () => ({ top: 50, height: 100, width: 100 }) // Mocking getBoundingClientRect + } + ]), + documentElement: { scrollTop: 100 } // Mock scroll position + }; + + // Mocking the DOM elements and their styles + document.body.innerHTML = ` +
Test Content
+
System Font Content
+ `; + + // Mocking window.getComputedStyle + global.window = { + getComputedStyle: (element) => { + return { + display: element.style.display || 'block', + visibility: element.style.visibility || 'visible', + opacity: element.style.opacity || '1', + // Add any other styles you need to mock + }; + } + }; + }); + + afterEach(() => { + // Clean up the global document mock + delete global.document; + delete global.window; // Clean up the global window mock + sinon.restore(); // Restore sinon mocks + }); + + describe('isSystemFont', () => { + it('should return true for system fonts', () => { + assert.strictEqual(beaconPreloadFonts.isSystemFont('Arial'), true); + }); + + it('should return false for non-system fonts', () => { + assert.strictEqual(beaconPreloadFonts.isSystemFont('Times New Roman'), false); + }); + }); + + describe('isElementVisible', () => { + it('should return true for visible elements', () => { + const element = document.createElement('div'); + element.style.display = 'block'; + element.style.visibility = 'visible'; + element.style.opacity = '1'; + assert.strictEqual(beaconPreloadFonts.isElementVisible(element), true); + }); + + it('should return false for hidden elements', () => { + const element = document.createElement('div'); + element.style.display = 'none'; + assert.strictEqual(beaconPreloadFonts.isElementVisible(element), false); + }); + }); + + describe('cleanUrl', () => { + it('should clean the URL correctly', () => { + const url = 'http://example.com/font.woff2?query=123#fragment'; + const cleanedUrl = beaconPreloadFonts.cleanUrl(url); + assert.strictEqual(cleanedUrl, 'http://example.com/font.woff2'); + }); + + it('should return the original URL if it fails to parse', () => { + const url = 'invalid-url'; + const cleanedUrl = beaconPreloadFonts.cleanUrl(url); + assert.strictEqual(cleanedUrl, url); + }); + }); + + describe('isElementAboveFold', () => { + it('isElementAboveFold should return true for visible elements above the fold', () => { + // Create a mock element + const element = document.createElement('div'); + document.body.appendChild(element); + element.style.display = 'block'; + element.style.visibility = 'visible'; + element.getBoundingClientRect = sinon.stub().returns({ + top: 0, + height: 100, + width: 100, + }); + + // Mock window properties + Object.defineProperty(window, 'innerHeight', { value: 200 }); + Object.defineProperty(window, 'pageYOffset', { value: 0 }); + + assert.strictEqual(beaconPreloadFonts.isElementAboveFold(element), true); + + // Clean up + document.body.removeChild(element); + }); + + it('isElementAboveFold should return false for hidden elements', () => { + // Create a mock element + const element = document.createElement('div'); + document.body.appendChild(element); + element.style.display = 'none'; + + assert.strictEqual(beaconPreloadFonts.isElementAboveFold(element), false); + + // Clean up + document.body.removeChild(element); + }); + + it('isElementAboveFold should return false for elements below the fold', () => { + // Create a mock element + const element = document.createElement('div'); + document.body.appendChild(element); + element.style.display = 'block'; + element.style.visibility = 'visible'; + element.getBoundingClientRect = sinon.stub().returns({ + top: 300, + height: 100, + width: 100, + }); + + // Mock window properties + Object.defineProperty(window, 'innerHeight', { value: 200 }); + Object.defineProperty(window, 'pageYOffset', { value: 0 }); + + assert.strictEqual(beaconPreloadFonts.isElementAboveFold(element), false); + + // Clean up + document.body.removeChild(element); + }); + }); + + describe('run', () => { + it('should log no fonts found if no fonts are above the fold', async () => { + // Mock methods + sinon.stub(beaconPreloadFonts, 'getNetworkLoadedFonts').returns(new Map()); + sinon.stub(beaconPreloadFonts, 'getFontFaceRules').returns({}); + sinon.stub(beaconPreloadFonts, 'processExternalFonts').returns({}); + + await beaconPreloadFonts.run(); + + assert.ok(loggerMock.logMessage.calledWith('No fonts found above the fold.')); + }); + + it('should log above the fold fonts when they are found', async () => { + // Mock methods + const mockFonts = { + allFonts: { + 'Font1': { variations: [{ url: 'http://example.com/font1.woff' }] } + }, + externalFonts: {}, + hostedFonts: {} + }; + sinon.stub(beaconPreloadFonts, 'getNetworkLoadedFonts').returns(new Map()); + sinon.stub(beaconPreloadFonts, 'getFontFaceRules').returns({}); + sinon.stub(beaconPreloadFonts, 'processExternalFonts').returns({}); + sinon.stub(beaconPreloadFonts, 'summarizeMatches').returns(mockFonts); + + await beaconPreloadFonts.run(); + + assert.ok(loggerMock.logMessage.calledWith('Above the fold fonts:', mockFonts)); + }); + }); + + describe('summarizeMatches', () => { + it('should summarize hosted and external fonts correctly', () => { + const externalFontsResults = { + 'http://example.com/font1.woff': { + elementCount: { aboveFold: 1, total: 1 }, + variations: [{ family: 'Font1', weight: '400', style: 'normal' }], + elements: [document.createElement('div')] + } + }; + + const hostedFonts = new Map(); + hostedFonts.set('Font2', { + variations: [{ weight: '400', style: 'normal' }], + elements: new Set([document.createElement('div')]), + urls: ['http://example.com/font2.woff'] + }); + + // Mock the isElementAboveFold method to return true for the hosted font element + sinon.stub(beaconPreloadFonts, 'isElementAboveFold').callsFake(() => { + return true; // Assume all elements are above the fold for this test + }); + + const networkLoadedFonts = new Map(); + networkLoadedFonts.set('http://example.com/font2.woff', 'http://example.com/font2.woff'); + + const result = beaconPreloadFonts.summarizeMatches(externalFontsResults, hostedFonts, networkLoadedFonts); + + assert.deepEqual(result.externalFonts, { + 'http://example.com/font1.woff': externalFontsResults['http://example.com/font1.woff'] + }); + assert.deepEqual(result.hostedFonts['Font2'].variations[0], { + weight: '400', + style: 'normal', + url: 'http://example.com/font2.woff', + elementCount: { aboveFold: 1, belowFold: 0, total: 1 } + }); + assert.ok(loggerMock.logMessage.notCalled); + }); + }); + + describe('processExternalFonts', () => { + it('should process external font pairs correctly', async () => { + const fontPairs = { + 'https://example.com/font1.woff2': [ + { family: 'Font1', weight: '400', style: 'normal' }, + ], + 'https://example.com/font2.woff2': [ + { family: 'Font2', weight: '400', style: 'normal' } + ] + }; + + // Mocking the DOM elements + const element = document.createElement('div'); + element.textContent = 'Test content'; + document.body.appendChild(element); + + // Mock the isElementAboveFold method to return true for the hosted font element + let callCount = 0; + sinon.stub(beaconPreloadFonts, 'isElementAboveFold').callsFake(() => { + callCount++; // Increment the counter on each call + return callCount === 1; // Return true only for the first call + }); + + // Mocking the getComputedStyle method + sinon.stub(window, 'getComputedStyle').returns({ + fontFamily: 'Font1, sans-serif', + fontWeight: '400', + fontStyle: 'normal' + }); + + const result = await beaconPreloadFonts.processExternalFonts(fontPairs); + + // Assertions + assert.strictEqual(typeof result, 'object', 'Result should be an object'); + assert.ok(result['https://example.com/font1.woff2'], 'Result should contain font1'); + assert.strictEqual(result['https://example.com/font1.woff2'].elementCount.total, 1, 'Font1 should have total count of 1'); + + // Clean up + document.body.removeChild(element); + window.getComputedStyle.restore(); + }); + }); +}); \ No newline at end of file