Skip to content

Commit

Permalink
feat: iframe serialization (#468)
Browse files Browse the repository at this point in the history
* ✨ Add iframe serialization

* ✅ Test iframe serialization

* 🚨 Make tslint okay with chai expressions
  • Loading branch information
Wil Wilsman committed Feb 21, 2020
1 parent cea40f4 commit 0bf23af
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 11 deletions.
47 changes: 37 additions & 10 deletions src/percy-agent-client/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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())
}
})
}
Expand Down
62 changes: 61 additions & 1 deletion test/unit/percy-agent-client/dom.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(`
<iframe id="frame-external" src="https://example.com"></iframe>
<iframe id="frame-input" srcdoc="<input/>"></iframe>
<iframe id="frame-js" src="javascript:void(
this.document.body.innerHTML = '<p>made with js src</p>'
)"></iframe>
<iframe id="frame-js-no-src"></iframe>
`)

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 = '<p>generated iframe</p>'

$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([
'<!DOCTYPE html><html><head></head><body>',
'<p>made with js src</p>',
'</body></html>',
].join(''))

expect($dom('#frame-js-no-src').attr('src')).to.be.undefined
expect($dom('#frame-js-no-src').attr('srcdoc')).to.equal([
'<!DOCTYPE html><html><head></head><body>',
'<p>generated iframe</p>',
'</body></html>',
].join(''))
})

it('serializes iframes that have been interacted with', () => {
expect($dom('#frame-input').attr('srcdoc')).to.match(new RegExp([
'^<!DOCTYPE html><html><head></head><body>',
'<input data-percy-element-id=".+?" value="iframe with an input">',
'</body></html>$',
].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
})
})
})
})
6 changes: 6 additions & 0 deletions test/unit/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../../tslint.json",
"rules": {
"no-unused-expression": false
}
}

0 comments on commit 0bf23af

Please sign in to comment.