diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 444e7efbc..6d217e24d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,7 @@ jobs: name: dist path: packages - run: yarn + - run: sudo apt update - name: Install browser dependencies run: sudo apt-get install -y libgbm-dev if: ${{ matrix.os == 'ubuntu-latest' }} diff --git a/packages/core/src/page.js b/packages/core/src/page.js index f970f0dd6..97efa64ad 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -207,6 +207,19 @@ export class Page { // serialize and capture a DOM snapshot this.log.debug('Serialize DOM', this.meta); + const waitTime = parseInt(process.env.LOADER_WAIT_TIMEOUT) || 2000; + + /* istanbul ignore next */ + const shouldWait = await this.eval(async () => { + /* eslint-disable-next-line no-undef */ + return PercyDOM.checkForLoader(); + }); + if (shouldWait) { + await new Promise(resolve => { + setTimeout(resolve, waitTime); + }); + } + /* istanbul ignore next: no instrumenting injected code */ let capture = await this.eval((_, options) => ({ /* eslint-disable-next-line no-undef */ diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index f7d5e38e8..a844f21a6 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -105,8 +105,9 @@ describe('Percy', () => { }); // expect required arguments are passed to PercyDOM.serialize - expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined }])); - + expect(evalSpy.calls.allArgs()[3][0]).toEqual(jasmine.any(Function)); + expect(evalSpy.calls.allArgs()[3][0].constructor.name).toBe('AsyncFunction'); + expect(evalSpy.calls.allArgs()[4]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined }])); expect(snapshot.url).toEqual('http://localhost:8000/'); expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({ html: '' + ( @@ -115,6 +116,47 @@ describe('Percy', () => { })); }); + it('should wait for n seconds if a loader is detected and env variable is present', async () => { + server.reply('/', () => [ + 200, + 'text/html', + `
+
+
+
+
` + ]); + process.env.LOADER_WAIT_TIMEOUT = 4000; + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + let startTime = Date.now(); + await page.snapshot({ disableShadowDOM: true }); + let endTime = Date.now(); + expect(endTime - startTime).toBeGreaterThanOrEqual(4000); + }); + + it('should wait for 2 seconds if loader is present but no env variable', async () => { + server.reply('/', () => [ + 200, + 'text/html', + `
+
+
+
+
` + ]); + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + let startTime = Date.now(); + await page.snapshot({ disableShadowDOM: true }); + let endTime = Date.now(); + expect(endTime - startTime).toBeGreaterThanOrEqual(2000); + }); + describe('.start()', () => { // rather than stub prototypes, extend and mock class TestPercy extends Percy { diff --git a/packages/dom/src/check-dom-loader.js b/packages/dom/src/check-dom-loader.js new file mode 100644 index 000000000..f48eb1215 --- /dev/null +++ b/packages/dom/src/check-dom-loader.js @@ -0,0 +1,67 @@ +// Determines if an element is visible +export function isElementVisible(el) { + const style = window.getComputedStyle(el); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + parseFloat(style.opacity) > 0 + ); +} + +// Checks if an element is a loader element by traversing its children +export function isLoaderElement(el, maxDepth = 2, currentDepth = 0) { + if (currentDepth >= maxDepth) return false; + + const children = el.children; + if (children.length === 0) return true; + + for (let i = 0; i < children.length; i++) { + if (!isLoaderElement(children[i], maxDepth, currentDepth + 1)) return false; + } + + return true; +} + +// Checks for loader elements in the DOM +export function checkForLoader() { + const loaders = Array.from(document.querySelectorAll('*')).filter(el => + (typeof el.className === 'string' && el.className.includes('loader')) || + (typeof el.id === 'string' && el.id.includes('loader')) + ); + + return loaders.some(loader => { + const parent = loader.parentElement; + + if (!isElementVisible(loader) || !isElementVisible(parent)) return false; + if (!isLoaderElement(loader)) return false; + + const parentRect = parent.getBoundingClientRect(); + const loaderRect = loader.getBoundingClientRect(); + + const viewportWidth = window.innerWidth; + const viewportHeight = Math.max( + document.documentElement.scrollHeight, + window.innerHeight + ); + + if (parentRect.width > loaderRect.width && parentRect.height > loaderRect.height) { + const widthPercentage = (parentRect.width / viewportWidth) * 100; + const heightPercentage = (parentRect.height / viewportHeight) * 100; + + if (widthPercentage >= 75 && heightPercentage >= 75) { + return true; + } + } else { + const widthPercentage = (loaderRect.width / viewportWidth) * 100; + const heightPercentage = (loaderRect.height / viewportHeight) * 100; + + if (widthPercentage >= 75 && heightPercentage >= 75) { + return true; + } + } + + return false; + }); +} + +export default { checkForLoader }; diff --git a/packages/dom/src/index.js b/packages/dom/src/index.js index a449d17f3..e93afe55a 100644 --- a/packages/dom/src/index.js +++ b/packages/dom/src/index.js @@ -7,3 +7,4 @@ export { } from './serialize-dom'; export { loadAllSrcsetLinks } from './serialize-image-srcset'; +export { checkForLoader } from './check-dom-loader'; diff --git a/packages/dom/test/check-dom-loader.test.js b/packages/dom/test/check-dom-loader.test.js new file mode 100644 index 000000000..6dfc3d03c --- /dev/null +++ b/packages/dom/test/check-dom-loader.test.js @@ -0,0 +1,99 @@ +import { checkForLoader } from '../src/check-dom-loader'; +import { withExample } from './helpers'; + +describe('checkForLoader', () => { + let div, loaderElement; + + beforeEach(() => { + withExample('
', { showLoader: true }); + + loaderElement = document.querySelector('.loader'); + loaderElement.style.display = 'block'; + loaderElement.style.visibility = 'visible'; + loaderElement.style.opacity = '1'; + div = document.querySelector('.parent'); + div.style.display = 'block'; + div.style.visibility = 'visible'; + div.style.opacity = '1'; + }); + + afterEach(() => { + loaderElement = null; + div = null; + }); + + it('should return true if the loader is visible and meets the size percentage criteria', () => { + loaderElement.style.width = '800px'; + loaderElement.style.height = '600px'; + const result = checkForLoader(); + expect(result).toBe(true); + }); + + it('should return true if parent meets the size percentage criteria', () => { + div.style.width = '800px'; + div.style.height = '3000px'; + loaderElement.style.width = '600px'; + loaderElement.style.height = '500px'; + + const result = checkForLoader(); + expect(result).toBe(true); + }); + + it('should return false if one of percentage criteria fails', () => { + div.style.width = '800px'; + div.style.height = '200px'; + loaderElement.style.width = '600px'; + loaderElement.style.height = '500px'; + + const result = checkForLoader(); + expect(result).toBe(false); + }); + + it('should return true if loader has upto depth 1 children', () => { + const child1 = document.createElement('div'); + div.style.height = '6000px'; + loaderElement.appendChild(child1); + const result = checkForLoader(); + expect(result).toBe(true); + }); + + it('should return false if the loader element is not visible', () => { + loaderElement.style.visibility = 'hidden'; + + const result = checkForLoader(); + expect(result).toBe(false); + }); + + it('should return false if the loader element is inside an invisible parent', () => { + div.style.visibility = 'hidden'; + + const result = checkForLoader(); + expect(result).toBe(false); + }); + + it('should return false if the loader does not meet the size percentage criteria', () => { + div.style.width = '200px'; + div.style.height = '200px'; + loaderElement.style.width = '100px'; + loaderElement.style.height = '100px'; + + const result = checkForLoader(); + expect(result).toBe(false); + }); + + it('should return false if no loader element is found', () => { + div.removeChild(loaderElement); + + const result = checkForLoader(); + expect(result).toBe(false); + }); + + it('should return false if loader has upto depth 3 children', () => { + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + child1.appendChild(child2); + loaderElement.appendChild(child1); + const result = checkForLoader(); + expect(result).toBe(false); + }); +}); diff --git a/packages/dom/test/helpers.js b/packages/dom/test/helpers.js index 559fc95c4..290b67746 100644 --- a/packages/dom/test/helpers.js +++ b/packages/dom/test/helpers.js @@ -3,7 +3,7 @@ export const chromeBrowser = 'CHROME'; export const firefoxBrowser = 'FIREFOX'; // create and cleanup testing DOM -export function withExample(html, options = { withShadow: true, withRestrictedShadow: false, invalidTagsOutsideBody: false }) { +export function withExample(html, options = { withShadow: true, withRestrictedShadow: false, invalidTagsOutsideBody: false, showLoader: false }) { let $test = document.getElementById('test'); if ($test) $test.remove(); @@ -25,6 +25,22 @@ export function withExample(html, options = { withShadow: true, withRestrictedSh document.body.appendChild($testShadow); } + // Add loader if the option is true + if (options.showLoader) { + const parentElement = document.createElement('div'); + document.body.appendChild(parentElement); + parentElement.style.width = '800px'; + parentElement.style.height = '600px'; + parentElement.classList.add('parent'); + const loaderElement = document.createElement('div'); + loaderElement.style.width = '600px'; + loaderElement.style.height = '500px'; + loaderElement.classList.add('loader'); + parentElement.appendChild(loaderElement); + window.innerWidth = 1024; + window.innerHeight = 768; + } + if (options.withRestrictedShadow) { $testShadow = document.createElement('div'); $testShadow.id = 'test-shadow';