From 8e25bbfddce70e1c142bb879f9467061f6696dd7 Mon Sep 17 00:00:00 2001 From: WordPress Fan <146129302+wordpressfan@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:14:58 +0300 Subject: [PATCH] Add LRC functionality (#20) * start adding the beaconlrc class * finalize the beacon manager class * temporarily fix tests * Return only hash as payload * Remove duplicate payload * Only add child element below the fold threshold and parent elements only below the fold * use config threshold instead of static value * Revert "use config threshold instead of static value" This reverts commit b4f633bc0168131c7d606615d88aa11bf6a9f56f. * use logger class and comment the xpath for now * Replace static values with config * start doing as grooming * get only elements that have hash * Added test for lrc * Best practices * Additional best practices * start considering viewport on element distance calculation * adjust imports and the log message * change the structure of test to handle more cases and use sinon for mocking * test * test 2 * fix tests attempt 1 * add more tests * add tests for run method * add more tests * remove not used argument --------- Co-authored-by: Michael Lee Co-authored-by: Mathieu Lamiot --- src/BeaconLrc.js | 143 ++++++++++++++++++++++ src/BeaconManager.js | 21 +++- src/Logger.js | 7 ++ src/Utils.js | 12 +- test/BeaconLrc.test.js | 235 +++++++++++++++++++++++++++++++++++++ test/BeaconManager.test.js | 2 +- 6 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 src/BeaconLrc.js create mode 100644 test/BeaconLrc.test.js diff --git a/src/BeaconLrc.js b/src/BeaconLrc.js new file mode 100644 index 0000000..52fd10d --- /dev/null +++ b/src/BeaconLrc.js @@ -0,0 +1,143 @@ +'use strict'; + +import BeaconUtils from "./Utils.js"; + +class BeaconLrc { + constructor(config, logger) { + this.config = config; + this.logger = logger; + this.lazyRenderElements = []; + } + + async run() { + try { + const elementsInView = this._getLazyRenderElements(); + if (elementsInView) { + this._processElements(elementsInView); + } + } catch (err) { + this.errorCode = 'script_error'; + this.logger.logMessage('Script Error: ' + err); + } + } + + _getLazyRenderElements() { + const elements = document.querySelectorAll('[data-rocket-location-hash]'); + + if (elements.length <= 0) { + return []; + } + + const validElements = Array.from(elements).filter(element => !this._skipElement(element)); + + return validElements.map(element => ({ + element: element, + depth: this._getElementDepth(element), + distance: this._getElementDistance(element), + hash: this._getLocationHash(element) + })); + } + + _getElementDepth(element) { + let depth = 0; + let parent = element.parentElement; + while (parent) { + depth++; + parent = parent.parentElement; + } + return depth; + } + + _getElementDistance(element) { + const rect = element.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + return Math.max(0, rect.top + scrollTop - BeaconUtils.getScreenHeight()); + } + + _skipElement(element) { + const skipStrings = this.config.skipStrings || ['memex']; + if (!element || !element.id) return false; + return skipStrings.some(str => element.id.toLowerCase().includes(str.toLowerCase())); + } + + _shouldSkipElement(element, exclusions) { + if (!element) return false; + for (let i = 0; i < exclusions.length; i++) { + const [attribute, pattern] = exclusions[i]; + const attributeValue = element.getAttribute(attribute); + if (attributeValue && new RegExp(pattern, 'i').test(attributeValue)) { + return true; + } + } + return false; + } + + _processElements(elements) { + elements.forEach(({ element, depth, distance, hash }) => { + if (this._shouldSkipElement(element, this.config.exclusions || [])) { + return; + } + + if ( 'No hash detected' === hash ) { + return; + } + + const can_push_hash = element.parentElement && this._getElementDistance(element.parentElement) < this.config.lrc_threshold && distance >= this.config.lrc_threshold; + + const color = can_push_hash ? "green" : distance === 0 ? "red" : ""; + this.logger.logColoredMessage( `${'\t'.repeat(depth)}${element.tagName} (Depth: ${depth}, Distance from viewport bottom: ${distance}px)`, color ); + + //const xpath = this._getXPath(element); + //console.log(`%c${'\t'.repeat(depth)}Xpath: ${xpath}`, style); + + this.logger.logColoredMessage(`${'\t'.repeat(depth)}Location hash: ${hash}`, color); + + this.logger.logColoredMessage(`${'\t'.repeat(depth)}Dimensions Client Height: ${element.clientHeight}`, color); + + if (can_push_hash) { + this.lazyRenderElements.push(hash); // Push the hash + this.logger.logMessage(`Element pushed with hash: ${hash}`); + } + }); + } + + _getXPath(element) { + if (element && element.id !== "") { + return `//*[@id="${element.id}"]`; + } + + return this._getElementXPath(element); + } + + _getElementXPath(element) { + if (element === document.body) { + return '/html/body'; + } + const position = this._getElementPosition(element); + return `${this._getElementXPath(element.parentNode)}/${element.nodeName.toLowerCase()}[${position}]`; + } + + _getElementPosition(element) { + let pos = 1; + let sibling = element.previousElementSibling; + while (sibling) { + if (sibling.nodeName === element.nodeName) { + pos++; + } + sibling = sibling.previousElementSibling; + } + return pos; + } + + _getLocationHash(element) { + return element.hasAttribute('data-rocket-location-hash') + ? element.getAttribute('data-rocket-location-hash') + : 'No hash detected'; + } + + getResults() { + return this.lazyRenderElements; + } +} + +export default BeaconLrc; \ No newline at end of file diff --git a/src/BeaconManager.js b/src/BeaconManager.js index 3610286..4f62581 100644 --- a/src/BeaconManager.js +++ b/src/BeaconManager.js @@ -1,6 +1,7 @@ 'use strict'; import BeaconLcp from "./BeaconLcp.js"; +import BeaconLrc from "./BeaconLrc.js"; import BeaconUtils from "./Utils.js"; import Logger from "./Logger.js"; @@ -8,6 +9,7 @@ class BeaconManager { constructor(config) { this.config = config; this.lcpBeacon = null; + this.lrcBeacon = null; this.infiniteLoopId = null; this.errorCode = ''; this.logger = new Logger(this.config.debug); @@ -25,21 +27,29 @@ class BeaconManager { }, 10000); const isGeneratedBefore = await this._getGeneratedBefore(); - let shouldSaveResultsIntoDB = false; // OCI / LCP / ATF const shouldGenerateLcp = ( this.config.status.atf && (isGeneratedBefore === false || isGeneratedBefore.lcp === false) ); + const shouldGeneratelrc = ( + this.config.status.lrc && (isGeneratedBefore === false || isGeneratedBefore.lrc === false) + ); if (shouldGenerateLcp) { this.lcpBeacon = new BeaconLcp(this.config, this.logger); await this.lcpBeacon.run(); - shouldSaveResultsIntoDB = true; } else { - this.logger.logMessage('Not running BeaconLcp because data is already available'); + this.logger.logMessage('Not running BeaconLcp because data is already available or feature is disabled'); + } + + if (shouldGeneratelrc) { + this.lrcBeacon = new BeaconLrc(this.config, this.logger); + await this.lrcBeacon.run(); + } else { + this.logger.logMessage('Not running BeaconLrc because data is already available or feature is disabled'); } - if (shouldSaveResultsIntoDB) { + if (shouldGenerateLcp || shouldGeneratelrc) { this._saveFinalResultIntoDB(); } else { this.logger.logMessage("Not saving results into DB as no beacon features ran."); @@ -84,7 +94,8 @@ class BeaconManager { _saveFinalResultIntoDB() { const results = { - lcp: this.lcpBeacon ? this.lcpBeacon.getResults() : null + lcp: this.lcpBeacon ? this.lcpBeacon.getResults() : null, + lrc: this.lrcBeacon ? this.lrcBeacon.getResults() : null }; const data = new FormData(); diff --git a/src/Logger.js b/src/Logger.js index 2265ab7..86010b5 100644 --- a/src/Logger.js +++ b/src/Logger.js @@ -11,6 +11,13 @@ class Logger { } console.log(msg); } + + logColoredMessage( msg, color = 'green' ) { + if (!this.enabled) { + return; + } + console.log(`%c${msg}`, `color: ${color};`); + } } export default Logger; \ No newline at end of file diff --git a/src/Utils.js b/src/Utils.js index 233f47f..98833de 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1,9 +1,17 @@ 'use strict'; class BeaconUtils { + static getScreenWidth() { + return window.innerWidth || document.documentElement.clientWidth; + } + + static getScreenHeight() { + return window.innerHeight || document.documentElement.clientHeight; + } + static isNotValidScreensize( is_mobile, threshold ) { - const screenWidth = window.innerWidth || document.documentElement.clientWidth; - const screenHeight = window.innerHeight || document.documentElement.clientHeight; + const screenWidth = this.getScreenWidth(); + const screenHeight = this.getScreenHeight(); const isNotValidForMobile = is_mobile && (screenWidth > threshold.width || screenHeight > threshold.height); diff --git a/test/BeaconLrc.test.js b/test/BeaconLrc.test.js new file mode 100644 index 0000000..d0724be --- /dev/null +++ b/test/BeaconLrc.test.js @@ -0,0 +1,235 @@ +import assert from 'assert'; +import BeaconLrc from '../src/BeaconLrc.js'; +import sinon from "sinon"; + +describe('BeaconLrc', function() { + let beaconLrc; + let mockElements; + + beforeEach(function() { + mockElements = [ + { + getBoundingClientRect: () => { + return { + top : 0, + }; + }, + getAttribute: () => 'hash1', + hasAttribute: () => true, + dataset: { rocketLocationHash: 'hash1' } + }, + { + getBoundingClientRect: () => { + return { + top : 800, + }; + }, + getAttribute: () => 'hash2', + hasAttribute: () => true, + dataset: { rocketLocationHash: 'hash2' } + }, + { + getBoundingClientRect: () => { + return { + top : 1000, + }; + }, + getAttribute: () => 'hash3', + hasAttribute: () => true, + dataset: { rocketLocationHash: 'hash3' } + }, + { + getBoundingClientRect: () => { + return { + top : -300, + }; + }, + getAttribute: () => 'hash4', + hasAttribute: () => true, + dataset: { rocketLocationHash: 'hash4' } + }, + ]; + + // Mocking document.querySelectorAll + global.document = { + querySelectorAll: (selector) => { + if (selector === '[data-rocket-location-hash]') { + return mockElements; + } + return []; + }, + documentElement: { scrollTop: 100 } // Ensure documentElement is part of the mock + }; + + const config = { skipStrings: ['memex'] }; + const logger = { + logMessage: (message) => { + console.log(`Log: ${message}`); + }, + logColoredMessage: (message, color) => { + console.log(`%c${message}`, `color: ${color}`); + } + }; + beaconLrc = new BeaconLrc(config, logger); + + // Mocking window.pageYOffset + global.window = { pageYOffset: 100, innerHeight: 500 }; + }); + + afterEach(function() { + delete global.window; + delete global.document; + }); + + it('should return empty elements', function() { + global.document = { + querySelectorAll: () => { + return []; + }, + }; + const elements = beaconLrc._getLazyRenderElements(); + assert(Array.isArray(elements)); + assert.strictEqual(elements.length, 0); + }); + + it('should return valid elements with depth, distance, and hash', function() { + const _getElementDepthStub = sinon.stub(beaconLrc, '_getElementDepth'); + _getElementDepthStub.returns(1); + + const _skipElementStub = sinon.stub(beaconLrc, '_skipElement'); + _skipElementStub.withArgs(mockElements[2]).returns(true); + _skipElementStub.withArgs(mockElements[3]).returns(true); + + const elements = beaconLrc._getLazyRenderElements(); + assert(Array.isArray(elements)); + assert.strictEqual(elements.length, 2); + + assert.strictEqual(elements[0].hash, 'hash1'); + assert.strictEqual(elements[0].depth, 1); + assert.strictEqual(elements[0].distance, 0); + + assert.strictEqual(elements[1].hash, 'hash2'); + assert.strictEqual(elements[1].depth, 1); + assert.strictEqual(elements[1].distance, 400); + + _getElementDepthStub.restore(); + _skipElementStub.restore(); + }); + + it('should skip elements based on config skipStrings', function() { + const _getElementDepthStub = sinon.stub(beaconLrc, '_getElementDepth'); + _getElementDepthStub.returns(1); + + const _skipElementStub = sinon.stub(beaconLrc, '_skipElement'); + _skipElementStub.withArgs(mockElements[2]).returns(true); + _skipElementStub.withArgs(mockElements[3]).returns(true); + + const elements = beaconLrc._getLazyRenderElements(); + const skippedElement = elements.find(el => el.hash === 'hash3'); + assert.strictEqual(skippedElement, undefined); + + _getElementDepthStub.restore(); + _skipElementStub.restore(); + }); + + it('should return correct distance', () => { + const distance = beaconLrc._getElementDistance(mockElements[2]); + assert.strictEqual(distance, 600); + }); + + it('should return 0 if distance is negative', () => { + const distance = beaconLrc._getElementDistance(mockElements[3]); + assert.strictEqual(distance, 0); + }); + + it('should return correct depth', () => { + const elementWithNoParent = { + parentElement: null, + }; + assert.strictEqual(beaconLrc._getElementDepth(elementWithNoParent), 0); + + const elementWithoneParent = { + parentElement: { + tagName: 'DIV', + }, + }; + assert.strictEqual(beaconLrc._getElementDepth(elementWithoneParent), 1); + + const elementWithTwoLevels = { + parentElement: { + tagName: 'DIV', + parentElement: { + tagName: 'DIV', + }, + }, + }; + assert.strictEqual(beaconLrc._getElementDepth(elementWithTwoLevels), 2); + }); + + it('_skipElement', () => { + // Empty config + const configStub = sinon.stub(beaconLrc, 'config'); + configStub.value({}); + assert.strictEqual(beaconLrc._skipElement({id: 'anyid'}), false); + + // Empty element + assert.strictEqual(beaconLrc._skipElement(), false); + + // Custom config + configStub.value({skipStrings: ['anyid', 'customid']}); + assert.strictEqual(beaconLrc._skipElement({id: 'anyid'}), true); + + // Case-insensitive + configStub.value({skipStrings: ['aNyid', 'customid']}); + assert.strictEqual(beaconLrc._skipElement({id: 'AnyId'}), true); + + configStub.restore(); + }); + + it('run with empty elements', async () => { + const beaconMock = sinon.mock(beaconLrc); + + // Empty elements + beaconMock.expects("_getLazyRenderElements").returns(''); + beaconMock.expects("_processElements").withArgs('').never(); + await beaconLrc.run(); + + beaconMock.restore(); + }); + + it('run with thrown errors', async () => { + const beaconMock = sinon.mock(beaconLrc); + + // Throws error + beaconMock.expects("_getLazyRenderElements").throws('test error'); + beaconMock.expects("_processElements").never(); + await beaconLrc.run(); + beaconMock.verify(); + + beaconMock.restore(); + }); + + it('run with valid elements', async () => { + const beaconMock = sinon.mock(beaconLrc); + + // Valid elements + beaconMock.expects("_getLazyRenderElements").returns(['test']); + beaconMock.expects("_processElements").withArgs(['test']).once(); + await beaconLrc.run(); + + beaconMock.restore(); + }); + + it('_getXPath', () => { + const _getElementXPathStub = sinon.stub(beaconLrc, '_getElementXPath'); + _getElementXPathStub.returns('test'); + + // No element + assert.strictEqual(beaconLrc._getXPath(), 'test'); + + // No ID + assert.strictEqual(beaconLrc._getXPath({id: 'testID'}), '//*[@id="testID"]'); + + _getElementXPathStub.restore(); + }); +}); \ No newline at end of file diff --git a/test/BeaconManager.test.js b/test/BeaconManager.test.js index 4471bd8..99b2f68 100644 --- a/test/BeaconManager.test.js +++ b/test/BeaconManager.test.js @@ -357,7 +357,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})); + const expectedResults = JSON.parse(JSON.stringify({lcp : beacon.lcpBeacon.performanceImages, lrc: null})); assert.deepStrictEqual(JSON.parse(sentDataObject['results']), expectedResults); assert.strictEqual(sentDataObject['status'], beacon._getFinalStatus()); sinon.assert.calledOnce(finalizeSpy);