From 0bf23af3829b204f7e53363e9adff4d523c38b77 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Fri, 21 Feb 2020 15:24:27 -0600 Subject: [PATCH] feat: iframe serialization (#468) * :sparkles: Add iframe serialization * :white_check_mark: Test iframe serialization * :rotating_light: Make tslint okay with chai expressions --- src/percy-agent-client/dom.ts | 47 ++++++++++++++---- test/unit/percy-agent-client/dom.test.ts | 62 +++++++++++++++++++++++- test/unit/tslint.json | 6 +++ 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 test/unit/tslint.json diff --git a/src/percy-agent-client/dom.ts b/src/percy-agent-client/dom.ts index dd98669a..89ee2bf8 100644 --- a/src/percy-agent-client/dom.ts +++ b/src/percy-agent-client/dom.ts @@ -93,6 +93,7 @@ class DOM { */ private stabilizeDOM(clonedDOM: HTMLDocument): HTMLElement { this.serializeInputElements(clonedDOM) + this.serializeFrameElements(clonedDOM) // We only want to serialize the CSSOM if JS isn't enabled. if (!this.options.enableJavaScript) { @@ -155,6 +156,35 @@ class DOM { }) } + private serializeFrameElements(clonedDOM: HTMLDocument) { + for (const frame of this.originalDOM.querySelectorAll('iframe')) { + const percyElementId = frame.getAttribute('data-percy-element-id') + const cloned = clonedDOM.querySelector(`[data-percy-element-id="${percyElementId}"]`) + + // delete frames within the head since they usually break pages when + // rerendered and do not effect the visuals of a page + if (clonedDOM.head.contains(cloned)) { + cloned!.remove() + + // if the frame document is accessible, we can serialize it + } else if (frame.contentDocument) { + const builtWithJs = !frame.srcdoc && (!frame.src || frame.src.split(':')[0] === 'javascript') + + // js is enabled and this frame was built with js, don't serialize it + if (this.options.enableJavaScript && builtWithJs) { continue } + + // the frame has yet to load and wasn't built with js, it is unsafe to serialize + if (!builtWithJs && !frame.contentWindow!.performance.timing.loadEventEnd) { continue } + + // recersively serialize contents and assign to srcdoc + const frameDOM = new DOM(frame.contentDocument, this.options) + cloned!.setAttribute('srcdoc', frameDOM.snapshotString()) + // srcdoc cannot exist in tandem with src + cloned!.removeAttribute('src') + } + } + } + /** * Capture in-memory styles & serialize those styles into the cloned DOM. * @@ -202,18 +232,15 @@ class DOM { * */ private mutateOriginalDOM() { - function createUID($el: Element) { - const ID = `_${Math.random().toString(36).substr(2, 9)}` - - $el.setAttribute('data-percy-element-id', ID) - } - + const createUID = () => `_${Math.random().toString(36).substr(2, 9)}` const formNodes = this.originalDOM.querySelectorAll(FORM_ELEMENTS_SELECTOR) - const formElements = Array.from(formNodes) as HTMLFormElement[] - // loop through each form element and apply an ID for serialization later - formElements.forEach((elem) => { + const frameNodes = this.originalDOM.querySelectorAll('iframe') + const elements = [...formNodes, ...frameNodes] as HTMLElement[] + + // loop through each element and apply an ID for serialization later + elements.forEach((elem) => { if (!elem.getAttribute('data-percy-element-id')) { - createUID(elem) + elem.setAttribute('data-percy-element-id', createUID()) } }) } diff --git a/test/unit/percy-agent-client/dom.test.ts b/test/unit/percy-agent-client/dom.test.ts index 4d0f5802..6fff135d 100644 --- a/test/unit/percy-agent-client/dom.test.ts +++ b/test/unit/percy-agent-client/dom.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import * as cheerio from 'cheerio' // @ts-ignore -import { check, select, type } from 'interactor.js' +import { check, select, type, when } from 'interactor.js' import * as sinon from 'sinon' import DOM from '../../../src/percy-agent-client/dom' @@ -328,5 +328,65 @@ describe('DOM -', () => { expect(serializedCSSOM[0].innerText).to.equal('.box { height: 500px; width: 500px; background-color: green; }') }) }) + + describe('iframes', () => { + let $dom: CheerioStatic + + beforeEach(async () => { + createExample(` + + + + + `) + + const $frameInput = document.getElementById('frame-input') as HTMLIFrameElement + await when(() => !!$frameInput.contentWindow!.performance.timing.loadEventEnd) + await type($frameInput.contentDocument!.querySelector('input'), 'iframe with an input') + + const $frameJS = document.getElementById('frame-js-no-src') as HTMLIFrameElement + $frameJS.contentDocument!.body.innerHTML = '

generated iframe

' + + $dom = cheerio.load(new DOM(document).snapshotString()) + }) + + it('serializes iframes created with JS', () => { + expect($dom('#frame-js').attr('src')).to.be.undefined + expect($dom('#frame-js').attr('srcdoc')).to.equal([ + '', + '

made with js src

', + '', + ].join('')) + + expect($dom('#frame-js-no-src').attr('src')).to.be.undefined + expect($dom('#frame-js-no-src').attr('srcdoc')).to.equal([ + '', + '

generated iframe

', + '', + ].join('')) + }) + + it('serializes iframes that have been interacted with', () => { + expect($dom('#frame-input').attr('srcdoc')).to.match(new RegExp([ + '^', + '', + '$', + ].join(''))) + }) + + it('does not serialize iframes with CORS', () => { + expect($dom('#frame-external').attr('src')).to.equal('https://example.com') + expect($dom('#frame-external').attr('srcdoc')).to.be.undefined + }) + + it('does not serialize iframes created by JS when JS is enabled', () => { + $dom = cheerio.load(new DOM(document, { enableJavaScript: true }).snapshotString()) + expect($dom('#frame-js').attr('src')).to.not.be.undefined + expect($dom('#frame-js').attr('srcdoc')).to.be.undefined + expect($dom('#frame-js-no-src').attr('srcdoc')).to.be.undefined + }) + }) }) }) diff --git a/test/unit/tslint.json b/test/unit/tslint.json new file mode 100644 index 00000000..a5611c77 --- /dev/null +++ b/test/unit/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tslint.json", + "rules": { + "no-unused-expression": false + } +}