diff --git a/src/BeaconLcp.js b/src/BeaconLcp.js index 5dc5ab1..e052db6 100644 --- a/src/BeaconLcp.js +++ b/src/BeaconLcp.js @@ -57,7 +57,8 @@ class BeaconLcp { return ( item.rect.width > 0 && item.rect.height > 0 && - BeaconUtils.isIntersecting(item.rect) + BeaconUtils.isIntersecting(item.rect) && + BeaconUtils.isElementVisible(item.element) ); }) .map(item => ({ diff --git a/src/BeaconPreloadFonts.js b/src/BeaconPreloadFonts.js index 117f90c..f978c6c 100644 --- a/src/BeaconPreloadFonts.js +++ b/src/BeaconPreloadFonts.js @@ -1,4 +1,7 @@ 'use strict'; + +import BeaconUtils from "./Utils.js"; + class BeaconPreloadFonts { constructor(config, logger) { this.config = config; @@ -59,78 +62,14 @@ class BeaconPreloadFonts { /** * 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 also excludes elements with transparent text properties. - * It returns true if the element is visible, and false otherwise. + * This method delegates to BeaconUtils.isElementVisible() for consistent + * visibility checking across all beacons. * * @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(); - - // Exclude elements with transparent text properties - if (this.hasTransparentText(element)) { - return false; - } - - return !( - style.display === 'none' || - style.visibility === 'hidden' || - style.opacity === '0' || - rect.width === 0 || - rect.height === 0 - ); - } - - /** - * Checks if an element has transparent text properties. - * - * This method checks for specific CSS properties that make text invisible, - * such as `color: transparent`, `color: rgba(..., 0)`, `color: hsla(..., 0)`, - * `color: #...00` (8-digit hex with alpha = 0), and `filter: opacity(0)`. - * - * @param {Element} element - The element to check. - * @returns {boolean} True if the element has transparent text properties, false otherwise. - */ - hasTransparentText(element) { - const style = window.getComputedStyle(element); - - // Defensive check for style properties - const color = style.color || ''; - const filter = style.filter || ''; - - // Check for `color: transparent` - if (color === 'transparent') { - return true; - } - - // Check for `color: rgba(..., 0)` - const rgbaMatch = color.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*0\)/); - if (rgbaMatch) { - return true; - } - - // Check for `color: hsla(..., 0)` - const hslaMatch = color.match(/hsla\(\d+,\s*\d+%,\s*\d+%,\s*0\)/); - if (hslaMatch) { - return true; - } - - // Check for `color: #...00` (8-digit hex with alpha = 0) - const hexMatch = color.match(/#[0-9a-fA-F]{6}00/); - if (hexMatch) { - return true; - } - - // Check for `filter: opacity(0)` - if (filter.includes('opacity(0)')) { - return true; - } - - return false; + return BeaconUtils.isElementVisible(element); } /** diff --git a/src/Utils.js b/src/Utils.js index aba6cbc..021bbe5 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -39,6 +39,92 @@ class BeaconUtils { return window.pageYOffset > 0 || document.documentElement.scrollTop > 0; } + /** + * 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 also excludes elements with transparent text 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. + */ + static isElementVisible(element) { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + // Defensive check for style + if (!style) { + return false; + } + + // Exclude elements with transparent text properties + if (this.hasTransparentText(element)) { + return false; + } + + return !( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + rect.width === 0 || + rect.height === 0 + ); + } + + /** + * Checks if an element has transparent text properties. + * + * This method checks for specific CSS properties that make text invisible, + * such as `color: transparent`, `color: rgba(..., 0)`, `color: hsla(..., 0)`, + * `color: #...00` (8-digit hex with alpha = 0), and `filter: opacity(0)`. + * + * @param {Element} element - The element to check. + * @returns {boolean} True if the element has transparent text properties, false otherwise. + */ + static hasTransparentText(element) { + const style = window.getComputedStyle(element); + + // Defensive check for style properties + if (!style) { + return false; + } + + const color = style.color || ''; + const filter = style.filter || ''; + + // Check for `color: transparent` + if (color === 'transparent') { + return true; + } + + // Check for `color: rgba(..., 0)` + const rgbaMatch = color.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*0\)/); + if (rgbaMatch) { + return true; + } + + // Check for `color: hsla(..., 0)` + const hslaMatch = color.match(/hsla\(\d+,\s*\d+%,\s*\d+%,\s*0\)/); + if (hslaMatch) { + return true; + } + + // Check for `color: #...00` (8-digit hex with alpha = 0) + const hexMatch = color.match(/#[0-9a-fA-F]{6}00/); + if (hexMatch) { + return true; + } + + // Check for `filter: opacity(0)` + if (filter.includes('opacity(0)')) { + return true; + } + + return false; + } + } export default BeaconUtils; \ No newline at end of file diff --git a/test/BeaconLcp.test.js b/test/BeaconLcp.test.js index af13966..be57c0a 100644 --- a/test/BeaconLcp.test.js +++ b/test/BeaconLcp.test.js @@ -96,4 +96,272 @@ describe('BeaconManager', function() { assert.strictEqual(elementInfo, null); }); }); + + describe('#_generateLcpCandidates()', function() { + let mockElements; + + beforeEach(function() { + mockElements = [ + { + nodeName: 'IMG', + parentElement: { nodeName: 'div' }, + getBoundingClientRect: () => ({ + width: 300, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 300 + }) + }, + { + nodeName: 'IMG', + parentElement: { nodeName: 'div' }, + getBoundingClientRect: () => ({ + width: 250, + height: 150, + top: 10, + left: 10, + bottom: 160, + right: 260 + }) + }, + { + nodeName: 'IMG', + parentElement: { nodeName: 'div' }, + getBoundingClientRect: () => ({ + width: 200, + height: 100, + top: 20, + left: 20, + bottom: 120, + right: 220 + }) + } + ]; + + global.document = { + querySelectorAll: () => mockElements + }; + + global.window = { + ...global.window, + innerWidth: 1200, + innerHeight: 800, + getComputedStyle: sinon.stub() + }; + + // Mock BeaconUtils.isIntersecting to return true by default + const UtilsStub = sinon.stub(); + UtilsStub.isIntersecting = sinon.stub().returns(true); + beacon.constructor.Utils = UtilsStub; + }); + + afterEach(function() { + sinon.restore(); + }); + + it('should filter out elements with opacity: 0', function() { + // Setup: first element has opacity 0, second is visible + global.window.getComputedStyle + .onCall(0).returns({ display: 'block', visibility: 'visible', opacity: '0', color: 'rgb(0,0,0)', filter: '' }) + .onCall(1).returns({ display: 'block', visibility: 'visible', opacity: '0', color: 'rgb(0,0,0)', filter: '' }) + .onCall(2).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(3).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(4).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(5).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }); + + beacon.config = { elements: 'img' }; + + // Mock _getElementInfo to return valid info for visible elements + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Should only return 2 candidates (excluding the one with opacity: 0) + assert.strictEqual(candidates.length, 2); + assert.strictEqual(candidates[0].element, mockElements[1]); + assert.strictEqual(candidates[1].element, mockElements[2]); + }); + + it('should filter out elements with visibility: hidden', function() { + // Setup: first element has visibility hidden, others are visible + global.window.getComputedStyle + .onCall(0).returns({ display: 'block', visibility: 'hidden', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(1).returns({ display: 'block', visibility: 'hidden', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(2).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(3).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(4).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(5).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }); + + beacon.config = { elements: 'img' }; + + // Mock _getElementInfo to return valid info for visible elements + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Should only return 2 candidates (excluding the one with visibility: hidden) + assert.strictEqual(candidates.length, 2); + assert.strictEqual(candidates[0].element, mockElements[1]); + assert.strictEqual(candidates[1].element, mockElements[2]); + }); + + it('should filter out elements with display: none', function() { + // Setup: first element has display none, others are visible + global.window.getComputedStyle + .onCall(0).returns({ display: 'none', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(1).returns({ display: 'none', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(2).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(3).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(4).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(5).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }); + + beacon.config = { elements: 'img' }; + + // Mock _getElementInfo to return valid info for visible elements + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Should only return 2 candidates (excluding the one with display: none) + assert.strictEqual(candidates.length, 2); + assert.strictEqual(candidates[0].element, mockElements[1]); + assert.strictEqual(candidates[1].element, mockElements[2]); + }); + + it('should include elements with visible styles', function() { + // All elements are visible + global.window.getComputedStyle.returns({ display: 'block', visibility: 'visible', opacity: '1' }); + + beacon.config = { elements: 'img' }; + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + assert.strictEqual(candidates.length, 3); + // Should be sorted by area (largest first) + assert.strictEqual(candidates[0].element, mockElements[0]); // 300x200 = 60000 + assert.strictEqual(candidates[1].element, mockElements[1]); // 250x150 = 37500 + assert.strictEqual(candidates[2].element, mockElements[2]); // 200x100 = 20000 + }); + + it('should handle multiple hidden elements with different visibility issues', function() { + // Mix of visibility issues + global.window.getComputedStyle + .onCall(0).returns({ display: 'none', visibility: 'visible', opacity: '1' }) // hidden by display + .onCall(1).returns({ display: 'block', visibility: 'hidden', opacity: '1' }) // hidden by visibility + .onCall(2).returns({ display: 'block', visibility: 'visible', opacity: '0' }); // hidden by opacity + + beacon.config = { elements: 'img' }; + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // All elements should be filtered out + assert.strictEqual(candidates.length, 0); + }); + + it('should handle edge case with very low opacity but not zero', function() { + // Test with very low opacity (0.01) - should be included as it's not exactly 0 + global.window.getComputedStyle + .onCall(0).returns({ display: 'block', visibility: 'visible', opacity: '0.01', color: 'rgb(0,0,0)', filter: '' }) + .onCall(1).returns({ display: 'block', visibility: 'visible', opacity: '0.01', color: 'rgb(0,0,0)', filter: '' }) + .onCall(2).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(3).returns({ display: 'block', visibility: 'visible', opacity: '1', color: 'rgb(0,0,0)', filter: '' }) + .onCall(4).returns({ display: 'block', visibility: 'visible', opacity: '0', color: 'rgb(0,0,0)', filter: '' }) + .onCall(5).returns({ display: 'block', visibility: 'visible', opacity: '0', color: 'rgb(0,0,0)', filter: '' }); + + beacon.config = { elements: 'img' }; + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Should include elements with opacity 0.01 and 1, but not 0 + assert.strictEqual(candidates.length, 2); + assert.strictEqual(candidates[0].element, mockElements[0]); + assert.strictEqual(candidates[1].element, mockElements[1]); + }); + + it('should maintain existing functionality for elements with zero dimensions', function() { + // Test that elements with zero width/height are still filtered out + mockElements[0].getBoundingClientRect = () => ({ + width: 0, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 0 + }); + + global.window.getComputedStyle.returns({ display: 'block', visibility: 'visible', opacity: '1' }); + + beacon.config = { elements: 'img' }; + sinon.stub(beacon, '_getElementInfo').returns({ src: 'test.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Should exclude element with zero width + assert.strictEqual(candidates.length, 2); + assert.strictEqual(candidates[0].element, mockElements[1]); + assert.strictEqual(candidates[1].element, mockElements[2]); + }); + + it('should reproduce and fix the belivria.com bug scenario', function() { + // Simulate the belivria.com scenario from the bug report + const hiddenImage = { + nodeName: 'IMG', + src: 'test_inline2.jpeg', + parentElement: { nodeName: 'div' }, + getBoundingClientRect: () => ({ + width: 350, + height: 350, + top: 50, + left: 50, + bottom: 400, + right: 400 + }) + }; + + const visibleImage = { + nodeName: 'IMG', + src: 'img_nature.jpg', + parentElement: { nodeName: 'div' }, + getBoundingClientRect: () => ({ + width: 350, + height: 350, + top: 50, + left: 50, + bottom: 400, + right: 400 + }) + }; + + global.document.querySelectorAll = () => [hiddenImage, visibleImage]; + + // Mock computed styles - hidden image has opacity: 0, visible image has opacity: 1 + global.window.getComputedStyle + .withArgs(hiddenImage).returns({ display: 'block', visibility: 'visible', opacity: '0' }) + .withArgs(visibleImage).returns({ display: 'block', visibility: 'visible', opacity: '1' }); + + beacon.config = { elements: 'img' }; + + // Mock _getElementInfo to return valid image info + sinon.stub(beacon, '_getElementInfo') + .withArgs(hiddenImage).returns({ src: 'test_inline2.jpeg', type: 'img' }) + .withArgs(visibleImage).returns({ src: 'img_nature.jpg', type: 'img' }); + + const candidates = beacon._generateLcpCandidates(10); + + // Before fix: would return hiddenImage as first candidate due to DOM order + // After fix: should only return visibleImage + assert.strictEqual(candidates.length, 1); + assert.strictEqual(candidates[0].element, visibleImage); + assert.strictEqual(candidates[0].elementInfo.src, 'img_nature.jpg'); + + // Verify the hidden image is not in candidates + const hiddenImageCandidate = candidates.find(c => c.element === hiddenImage); + assert.strictEqual(hiddenImageCandidate, undefined); + }); + }); }); diff --git a/test/BeaconPreloadFonts.test.js b/test/BeaconPreloadFonts.test.js index 126bbe5..9edf080 100644 --- a/test/BeaconPreloadFonts.test.js +++ b/test/BeaconPreloadFonts.test.js @@ -185,252 +185,23 @@ describe('BeaconPreloadFonts', () => { element.style.visibility = 'visible'; element.style.opacity = '1'; - // Mock hasTransparentText to return true - sinon.stub(beaconPreloadFonts, 'hasTransparentText').returns(true); - - assert.strictEqual(beaconPreloadFonts.isElementVisible(element), false); - - // Restore the stub - beaconPreloadFonts.hasTransparentText.restore(); - }); - }); - - describe('hasTransparentText', () => { - it('should return true for elements with color: transparent', () => { - const element = document.createElement('div'); - // Mock getComputedStyle to return transparent color const originalGetComputedStyle = window.getComputedStyle; window.getComputedStyle = sinon.stub().returns({ color: 'transparent', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with rgba color with alpha 0', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return rgba with alpha 0 - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgba(255, 0, 0, 0)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with rgba color with alpha 0 and spaces', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return rgba with alpha 0 and spaces - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgba(255, 128, 64, 0)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with hsla color with alpha 0', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return hsla with alpha 0 - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'hsla(120, 50%, 50%, 0)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with 8-digit hex color ending in 00', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return 8-digit hex with alpha 0 - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: '#ff000000', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with uppercase 8-digit hex color ending in 00', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return uppercase 8-digit hex with alpha 0 - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: '#FF123A00', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return true for elements with filter: opacity(0)', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return filter with opacity(0) - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgb(0, 0, 0)', - filter: 'blur(5px) opacity(0) brightness(100%)' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), true); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with visible text properties', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return normal visible styles - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgb(0, 0, 0)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with rgba color with non-zero alpha', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return rgba with non-zero alpha - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgba(255, 0, 0, 0.5)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with hsla color with non-zero alpha', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return hsla with non-zero alpha - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'hsla(120, 50%, 50%, 0.8)', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with 8-digit hex color not ending in 00', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return 8-digit hex with non-zero alpha - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: '#ff0000ff', - filter: '' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with filter: opacity(1)', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return filter with opacity(1) - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgb(0, 0, 0)', - filter: 'blur(5px) opacity(1) brightness(100%)' + filter: '', + display: 'block', + visibility: 'visible', + opacity: '1' }); - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should return false for elements with no filter', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return no filter - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: 'rgb(0, 0, 0)', - filter: 'none' - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should handle null/undefined color and filter properties safely', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return null/undefined properties - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: null, - filter: undefined - }); - - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); - - // Restore original function - window.getComputedStyle = originalGetComputedStyle; - }); - - it('should handle empty color and filter properties safely', () => { - const element = document.createElement('div'); - - // Mock getComputedStyle to return empty properties - const originalGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = sinon.stub().returns({ - color: '', - filter: '' + // Add mock getBoundingClientRect + element.getBoundingClientRect = sinon.stub().returns({ + width: 100, + height: 100 }); - assert.strictEqual(beaconPreloadFonts.hasTransparentText(element), false); + assert.strictEqual(beaconPreloadFonts.isElementVisible(element), false); // Restore original function window.getComputedStyle = originalGetComputedStyle; diff --git a/test/Utils.test.js b/test/Utils.test.js index 3462f6b..24ae035 100644 --- a/test/Utils.test.js +++ b/test/Utils.test.js @@ -1,4 +1,5 @@ import assert from 'assert'; +import sinon from 'sinon'; import BeaconUtils from '../src/Utils.js'; import node_fetch from 'node-fetch'; global.fetch = node_fetch; @@ -67,4 +68,299 @@ describe('BeaconManager', function() { }); }); + describe('#isElementVisible', () => { + beforeEach(function () { + // Mock DOM elements and getComputedStyle + global.window = { + ...global.window, + getComputedStyle: sinon.stub() + }; + + global.document = { + createElement: () => ({ + getBoundingClientRect: () => ({ + width: 100, + height: 100 + }) + }) + }; + }); + + afterEach(function () { + if (global.window.getComputedStyle.restore) { + global.window.getComputedStyle.restore(); + } + }); + + it('should return true for visible elements', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'visible', + opacity: '1', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), true); + }); + + it('should return false for elements with display: none', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + display: 'none', + visibility: 'visible', + opacity: '1', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + + it('should return false for elements with visibility: hidden', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'hidden', + opacity: '1', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + + it('should return false for elements with opacity: 0', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'visible', + opacity: '0', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + + it('should return false for elements with zero width', () => { + const element = { + getBoundingClientRect: () => ({ + width: 0, + height: 100 + }) + }; + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'visible', + opacity: '1', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + + it('should return false for elements with zero height', () => { + const element = { + getBoundingClientRect: () => ({ + width: 100, + height: 0 + }) + }; + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'visible', + opacity: '1', + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + + it('should return false for elements with transparent text', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + display: 'block', + visibility: 'visible', + opacity: '1', + color: 'transparent', + filter: '' + }); + + assert.strictEqual(BeaconUtils.isElementVisible(element), false); + }); + }); + + describe('#hasTransparentText', () => { + beforeEach(function () { + global.window = { + ...global.window, + getComputedStyle: sinon.stub() + }; + + global.document = { + createElement: () => ({}) + }; + }); + + afterEach(function () { + if (global.window.getComputedStyle.restore) { + global.window.getComputedStyle.restore(); + } + }); + + it('should return true for elements with color: transparent', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'transparent', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with rgba color with alpha 0', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgba(255, 0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with rgba color with alpha 0 and spaces', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgba(255, 128, 64, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with hsla color with alpha 0', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'hsla(120, 50%, 50%, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with 8-digit hex color ending in 00', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: '#ff000000', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with uppercase 8-digit hex color ending in 00', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: '#FF123A00', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return true for elements with filter: opacity(0)', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgb(0, 0, 0)', + filter: 'blur(5px) opacity(0) brightness(100%)' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), true); + }); + + it('should return false for elements with visible text properties', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgb(0, 0, 0)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + + it('should return false for elements with rgba color with non-zero alpha', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgba(255, 0, 0, 0.5)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + + it('should return false for elements with hsla color with non-zero alpha', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'hsla(120, 50%, 50%, 0.8)', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + + it('should return false for elements with 8-digit hex color not ending in 00', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: '#ff0000ff', + filter: '' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + + it('should return false for elements with filter: opacity(1)', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgb(0, 0, 0)', + filter: 'blur(5px) opacity(1) brightness(100%)' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + + it('should return false for elements with no filter', () => { + const element = global.document.createElement('div'); + + global.window.getComputedStyle.returns({ + color: 'rgb(0, 0, 0)', + filter: 'none' + }); + + assert.strictEqual(BeaconUtils.hasTransparentText(element), false); + }); + }); + });