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';