@percy/dom
Advanced tools
Comparing version 1.29.0-beta.0 to 1.29.0-beta.1
@@ -9,44 +9,120 @@ (function() { | ||
// Creates a resource object from an element's unique ID and data URL | ||
function resourceFromDataURL(uid, dataURL) { | ||
// split dataURL into desired parts | ||
let [data, content] = dataURL.split(','); | ||
let [, mimetype] = data.split(':'); | ||
[mimetype] = mimetype.split(';'); | ||
// build a URL for the serialized asset | ||
let [, ext] = mimetype.split('/'); | ||
let path = `/__serialized__/${uid}.${ext}`; | ||
let url = rewriteLocalhostURL(new URL(path, document.URL).toString()); | ||
// return the url, base64 content, and mimetype | ||
return { | ||
url, | ||
content, | ||
mimetype | ||
}; | ||
} | ||
function resourceFromText(uid, mimetype, data) { | ||
// build a URL for the serialized asset | ||
let [, ext] = mimetype.split('/'); | ||
let path = `/__serialized__/${uid}.${ext}`; | ||
let url = rewriteLocalhostURL(new URL(path, document.URL).toString()); | ||
// return the url, text content, and mimetype | ||
return { | ||
url, | ||
content: data, | ||
mimetype | ||
}; | ||
} | ||
function styleSheetFromNode(node) { | ||
/* istanbul ignore if: sanity check */ | ||
if (node.sheet) return node.sheet; | ||
// Cloned style nodes don't have a sheet instance unless they are within | ||
// a document; we get it by temporarily adding the rules to DOM | ||
const tempStyle = node.cloneNode(); | ||
tempStyle.setAttribute('data-percy-style-helper', ''); | ||
tempStyle.innerHTML = node.innerHTML; | ||
const clone = document.cloneNode(); | ||
clone.appendChild(tempStyle); | ||
const sheet = tempStyle.sheet; | ||
// Cleanup node | ||
tempStyle.remove(); | ||
return sheet; | ||
} | ||
function rewriteLocalhostURL(url) { | ||
return url.replace(/(http[s]{0,1}:\/\/)(localhost|127.0.0.1)[:\d+]*/, '$1render.percy.local'); | ||
} | ||
// Utility function to handle errors | ||
function handleErrors(error, prefixMessage) { | ||
let element = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; | ||
let additionalData = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; | ||
let elementData = {}; | ||
if (element) { | ||
elementData = { | ||
nodeName: element.nodeName, | ||
classNames: element.className, | ||
id: element.id | ||
}; | ||
} | ||
additionalData = { | ||
...additionalData, | ||
...elementData | ||
}; | ||
error.message += `\n${prefixMessage} \n${JSON.stringify(additionalData)}`; | ||
error.message += '\n Please validate that your DOM is as per W3C standards using any online tool'; | ||
error.handled = true; | ||
throw error; | ||
} | ||
// Translates JavaScript properties of inputs into DOM attributes. | ||
function serializeInputElements(_ref) { | ||
function serializeInputElements(ctx) { | ||
let { | ||
dom, | ||
clone, | ||
warnings | ||
} = _ref; | ||
clone | ||
} = ctx; | ||
for (let elem of dom.querySelectorAll('input, textarea, select')) { | ||
let inputId = elem.getAttribute('data-percy-element-id'); | ||
let cloneEl = clone.querySelector(`[data-percy-element-id="${inputId}"]`); | ||
switch (elem.type) { | ||
case 'checkbox': | ||
case 'radio': | ||
/* | ||
here we are removing the checked attr if present by default, | ||
so that only the current selected radio-button will have the checked attr present in the dom | ||
this happens because in html, | ||
when the checked attribute is present in the multiple radio-buttons for which only one can be selected at a time, | ||
the browser will only render the last checked radio-button by default, | ||
when a user selects any particular radio-button, the checked attribute on other buttons is not removed, | ||
hence sometimes it shows inconsistent state as html will still show the last radio as selected. | ||
*/ | ||
cloneEl.removeAttribute('checked'); | ||
if (elem.checked) { | ||
cloneEl.setAttribute('checked', ''); | ||
} | ||
break; | ||
case 'select-one': | ||
if (elem.selectedIndex !== -1) { | ||
cloneEl.options[elem.selectedIndex].setAttribute('selected', 'true'); | ||
} | ||
break; | ||
case 'select-multiple': | ||
for (let option of elem.selectedOptions) { | ||
cloneEl.options[option.index].setAttribute('selected', 'true'); | ||
} | ||
break; | ||
case 'textarea': | ||
cloneEl.innerHTML = elem.value; | ||
break; | ||
default: | ||
cloneEl.setAttribute('value', elem.value); | ||
try { | ||
let inputId = elem.getAttribute('data-percy-element-id'); | ||
let cloneEl = clone.querySelector(`[data-percy-element-id="${inputId}"]`); | ||
switch (elem.type) { | ||
case 'checkbox': | ||
case 'radio': | ||
/* | ||
here we are removing the checked attr if present by default, | ||
so that only the current selected radio-button will have the checked attr present in the dom | ||
this happens because in html, | ||
when the checked attribute is present in the multiple radio-buttons for which only one can be selected at a time, | ||
the browser will only render the last checked radio-button by default, | ||
when a user selects any particular radio-button, the checked attribute on other buttons is not removed, | ||
hence sometimes it shows inconsistent state as html will still show the last radio as selected. | ||
*/ | ||
cloneEl.removeAttribute('checked'); | ||
if (elem.checked) { | ||
cloneEl.setAttribute('checked', ''); | ||
} | ||
break; | ||
case 'select-one': | ||
if (elem.selectedIndex !== -1) { | ||
cloneEl.options[elem.selectedIndex].setAttribute('selected', 'true'); | ||
} | ||
break; | ||
case 'select-multiple': | ||
for (let option of elem.selectedOptions) { | ||
cloneEl.options[option.index].setAttribute('selected', 'true'); | ||
} | ||
break; | ||
case 'textarea': | ||
cloneEl.innerHTML = elem.value; | ||
break; | ||
default: | ||
cloneEl.setAttribute('value', elem.value); | ||
} | ||
} catch (err) { | ||
handleErrors(err, 'Error serializing input element: ', elem); | ||
} | ||
@@ -121,53 +197,2 @@ } | ||
// Creates a resource object from an element's unique ID and data URL | ||
function resourceFromDataURL(uid, dataURL) { | ||
// split dataURL into desired parts | ||
let [data, content] = dataURL.split(','); | ||
let [, mimetype] = data.split(':'); | ||
[mimetype] = mimetype.split(';'); | ||
// build a URL for the serialized asset | ||
let [, ext] = mimetype.split('/'); | ||
let path = `/__serialized__/${uid}.${ext}`; | ||
let url = rewriteLocalhostURL(new URL(path, document.URL).toString()); | ||
// return the url, base64 content, and mimetype | ||
return { | ||
url, | ||
content, | ||
mimetype | ||
}; | ||
} | ||
function resourceFromText(uid, mimetype, data) { | ||
// build a URL for the serialized asset | ||
let [, ext] = mimetype.split('/'); | ||
let path = `/__serialized__/${uid}.${ext}`; | ||
let url = rewriteLocalhostURL(new URL(path, document.URL).toString()); | ||
// return the url, text content, and mimetype | ||
return { | ||
url, | ||
content: data, | ||
mimetype | ||
}; | ||
} | ||
function styleSheetFromNode(node) { | ||
/* istanbul ignore if: sanity check */ | ||
if (node.sheet) return node.sheet; | ||
// Cloned style nodes don't have a sheet instance unless they are within | ||
// a document; we get it by temporarily adding the rules to DOM | ||
const tempStyle = node.cloneNode(); | ||
tempStyle.setAttribute('data-percy-style-helper', ''); | ||
tempStyle.innerHTML = node.innerHTML; | ||
const clone = document.cloneNode(); | ||
clone.appendChild(tempStyle); | ||
const sheet = tempStyle.sheet; | ||
// Cleanup node | ||
tempStyle.remove(); | ||
return sheet; | ||
} | ||
function rewriteLocalhostURL(url) { | ||
return url.replace(/(http[s]{0,1}:\/\/)(localhost|127.0.0.1)[:\d+]*/, '$1render.percy.local'); | ||
} | ||
// Returns a mostly random uid. | ||
@@ -216,3 +241,3 @@ function uid() { | ||
} | ||
function serializeCSSOM(_ref) { | ||
function serializeCSSOM(ctx) { | ||
let { | ||
@@ -224,3 +249,3 @@ dom, | ||
warnings | ||
} = _ref; | ||
} = ctx; | ||
// in-memory CSSOM into their respective DOM nodes. | ||
@@ -238,26 +263,40 @@ let styleSheets = null; | ||
if (isCSSOM(styleSheet)) { | ||
let styleId = styleSheet.ownerNode.getAttribute('data-percy-element-id'); | ||
let cloneOwnerNode = clone.querySelector(`[data-percy-element-id="${styleId}"]`); | ||
if (styleSheetsMatch(styleSheet, styleSheetFromNode(cloneOwnerNode))) continue; | ||
let style = document.createElement('style'); | ||
style.type = 'text/css'; | ||
style.setAttribute('data-percy-element-id', styleId); | ||
style.setAttribute('data-percy-cssom-serialized', 'true'); | ||
style.innerHTML = Array.from(styleSheet.cssRules).map(cssRule => cssRule.cssText).join('\n'); | ||
cloneOwnerNode.parentNode.insertBefore(style, cloneOwnerNode.nextSibling); | ||
cloneOwnerNode.remove(); | ||
let styleId; | ||
let cloneOwnerNode; | ||
try { | ||
styleId = styleSheet.ownerNode.getAttribute('data-percy-element-id'); | ||
cloneOwnerNode = clone.querySelector(`[data-percy-element-id="${styleId}"]`); | ||
if (styleSheetsMatch(styleSheet, styleSheetFromNode(cloneOwnerNode))) continue; | ||
let style = document.createElement('style'); | ||
style.type = 'text/css'; | ||
style.setAttribute('data-percy-element-id', styleId); | ||
style.setAttribute('data-percy-cssom-serialized', 'true'); | ||
style.innerHTML = Array.from(styleSheet.cssRules).map(cssRule => cssRule.cssText).join('\n'); | ||
cloneOwnerNode.parentNode.insertBefore(style, cloneOwnerNode.nextSibling); | ||
cloneOwnerNode.remove(); | ||
} catch (err) { | ||
handleErrors(err, 'Error serializing stylesheet: ', cloneOwnerNode, { | ||
styleId: styleId | ||
}); | ||
} | ||
} else if ((_styleSheet$href = styleSheet.href) !== null && _styleSheet$href !== void 0 && _styleSheet$href.startsWith('blob:')) { | ||
const styleLink = document.createElement('link'); | ||
styleLink.setAttribute('rel', 'stylesheet'); | ||
let resource = createStyleResource(styleSheet); | ||
resources.add(resource); | ||
styleLink.setAttribute('data-percy-blob-stylesheets-serialized', 'true'); | ||
styleLink.setAttribute('data-percy-serialized-attribute-href', resource.url); | ||
try { | ||
const styleLink = document.createElement('link'); | ||
styleLink.setAttribute('rel', 'stylesheet'); | ||
let resource = createStyleResource(styleSheet); | ||
resources.add(resource); | ||
styleLink.setAttribute('data-percy-blob-stylesheets-serialized', 'true'); | ||
styleLink.setAttribute('data-percy-serialized-attribute-href', resource.url); | ||
/* istanbul ignore next: tested, but coverage is stripped */ | ||
if (clone.constructor.name === 'HTMLDocument' || clone.constructor.name === 'DocumentFragment') { | ||
// handle document and iframe | ||
clone.body.prepend(styleLink); | ||
} else if (clone.constructor.name === 'ShadowRoot') { | ||
clone.prepend(styleLink); | ||
/* istanbul ignore next: tested, but coverage is stripped */ | ||
if (clone.constructor.name === 'HTMLDocument' || clone.constructor.name === 'DocumentFragment') { | ||
// handle document and iframe | ||
clone.body.prepend(styleLink); | ||
} else if (clone.constructor.name === 'ShadowRoot') { | ||
clone.prepend(styleLink); | ||
} | ||
} catch (err) { | ||
handleErrors(err, 'Error serializing stylesheet from blob: ', null, { | ||
stylesheetHref: styleSheet.href | ||
}); | ||
} | ||
@@ -297,3 +336,3 @@ } | ||
// Serialize in-memory canvas elements into images. | ||
function serializeCanvas(_ref) { | ||
function serializeCanvas(ctx) { | ||
let { | ||
@@ -303,45 +342,49 @@ dom, | ||
resources | ||
} = _ref; | ||
} = ctx; | ||
for (let canvas of dom.querySelectorAll('canvas')) { | ||
// Note: the `.toDataURL` API requires WebGL canvas elements to use | ||
// `preserveDrawingBuffer: true`. This is because `.toDataURL` uses the | ||
// drawing buffer, which is cleared after each render for WebGL by default. | ||
let dataUrl = canvas.toDataURL(); | ||
try { | ||
// Note: the `.toDataURL` API requires WebGL canvas elements to use | ||
// `preserveDrawingBuffer: true`. This is because `.toDataURL` uses the | ||
// drawing buffer, which is cleared after each render for WebGL by default. | ||
let dataUrl = canvas.toDataURL(); | ||
// skip empty canvases | ||
if (!dataUrl || dataUrl === 'data:,') continue; | ||
// skip empty canvases | ||
if (!dataUrl || dataUrl === 'data:,') continue; | ||
// get the element's percy id and create a resource for it | ||
let percyElementId = canvas.getAttribute('data-percy-element-id'); | ||
let resource = resourceFromDataURL(percyElementId, dataUrl); | ||
resources.add(resource); | ||
// get the element's percy id and create a resource for it | ||
let percyElementId = canvas.getAttribute('data-percy-element-id'); | ||
let resource = resourceFromDataURL(percyElementId, dataUrl); | ||
resources.add(resource); | ||
// create an image element in the cloned dom | ||
let img = document.createElement('img'); | ||
// use a data attribute to avoid making a real request | ||
img.setAttribute('data-percy-serialized-attribute-src', resource.url); | ||
// create an image element in the cloned dom | ||
let img = document.createElement('img'); | ||
// use a data attribute to avoid making a real request | ||
img.setAttribute('data-percy-serialized-attribute-src', resource.url); | ||
// copy canvas element attributes to the image element such as style, class, | ||
// or data attributes that may be targeted by CSS | ||
for (let { | ||
name, | ||
value | ||
} of canvas.attributes) { | ||
img.setAttribute(name, value); | ||
} | ||
// copy canvas element attributes to the image element such as style, class, | ||
// or data attributes that may be targeted by CSS | ||
for (let { | ||
name, | ||
value | ||
} of canvas.attributes) { | ||
img.setAttribute(name, value); | ||
} | ||
// mark the image as serialized (can be targeted by CSS) | ||
img.setAttribute('data-percy-canvas-serialized', ''); | ||
// set a default max width to account for canvases that might resize with JS | ||
img.style.maxWidth = img.style.maxWidth || '100%'; | ||
// mark the image as serialized (can be targeted by CSS) | ||
img.setAttribute('data-percy-canvas-serialized', ''); | ||
// set a default max width to account for canvases that might resize with JS | ||
img.style.maxWidth = img.style.maxWidth || '100%'; | ||
// insert the image into the cloned DOM and remove the cloned canvas element | ||
let cloneEl = clone.querySelector(`[data-percy-element-id=${percyElementId}]`); | ||
// `parentElement` for elements directly under shadow root is `null` -> Incase of Nested Shadow DOM. | ||
if (cloneEl.parentElement) { | ||
cloneEl.parentElement.insertBefore(img, cloneEl); | ||
} else { | ||
clone.insertBefore(img, cloneEl); | ||
// insert the image into the cloned DOM and remove the cloned canvas element | ||
let cloneEl = clone.querySelector(`[data-percy-element-id=${percyElementId}]`); | ||
// `parentElement` for elements directly under shadow root is `null` -> Incase of Nested Shadow DOM. | ||
if (cloneEl.parentElement) { | ||
cloneEl.parentElement.insertBefore(img, cloneEl); | ||
} else { | ||
clone.insertBefore(img, cloneEl); | ||
} | ||
cloneEl.remove(); | ||
} catch (err) { | ||
handleErrors(err, 'Error serializing canvas element: ', canvas); | ||
} | ||
cloneEl.remove(); | ||
} | ||
@@ -351,3 +394,3 @@ } | ||
// Captures the current frame of videos and sets the poster image | ||
function serializeVideos(_ref) { | ||
function serializeVideos(ctx) { | ||
let { | ||
@@ -358,28 +401,32 @@ dom, | ||
warnings | ||
} = _ref; | ||
} = ctx; | ||
for (let video of dom.querySelectorAll('video')) { | ||
// if the video already has a poster image, no work for us to do | ||
if (video.getAttribute('poster')) continue; | ||
let videoId = video.getAttribute('data-percy-element-id'); | ||
let cloneEl = clone.querySelector(`[data-percy-element-id="${videoId}"]`); | ||
let canvas = document.createElement('canvas'); | ||
let width = canvas.width = video.videoWidth; | ||
let height = canvas.height = video.videoHeight; | ||
let dataUrl; | ||
canvas.getContext('2d').drawImage(video, 0, 0, width, height); | ||
try { | ||
dataUrl = canvas.toDataURL(); | ||
} catch (e) { | ||
warnings.add(`data-percy-element-id="${videoId}" : ${e.toString()}`); | ||
} | ||
// if the video already has a poster image, no work for us to do | ||
if (video.getAttribute('poster')) continue; | ||
let videoId = video.getAttribute('data-percy-element-id'); | ||
let cloneEl = clone.querySelector(`[data-percy-element-id="${videoId}"]`); | ||
let canvas = document.createElement('canvas'); | ||
let width = canvas.width = video.videoWidth; | ||
let height = canvas.height = video.videoHeight; | ||
let dataUrl; | ||
canvas.getContext('2d').drawImage(video, 0, 0, width, height); | ||
try { | ||
dataUrl = canvas.toDataURL(); | ||
} catch (e) { | ||
warnings.add(`data-percy-element-id="${videoId}" : ${e.toString()}`); | ||
} | ||
// if the canvas produces a blank image, skip | ||
if (!dataUrl || dataUrl === 'data:,') continue; | ||
// if the canvas produces a blank image, skip | ||
if (!dataUrl || dataUrl === 'data:,') continue; | ||
// create a resource from the serialized data url | ||
let resource = resourceFromDataURL(videoId, dataUrl); | ||
resources.add(resource); | ||
// create a resource from the serialized data url | ||
let resource = resourceFromDataURL(videoId, dataUrl); | ||
resources.add(resource); | ||
// use a data attribute to avoid making a real request | ||
cloneEl.setAttribute('data-percy-serialized-attribute-poster', resource.url); | ||
// use a data attribute to avoid making a real request | ||
cloneEl.setAttribute('data-percy-serialized-attribute-poster', resource.url); | ||
} catch (err) { | ||
handleErrors(err, 'Error serializing video element: ', video); | ||
} | ||
} | ||
@@ -446,3 +493,3 @@ } | ||
const ignoreTags = ['NOSCRIPT']; | ||
function cloneNodeAndShadow(_ref) { | ||
function cloneNodeAndShadow(ctx) { | ||
let { | ||
@@ -452,45 +499,53 @@ dom, | ||
resources | ||
} = _ref; | ||
} = ctx; | ||
// clones shadow DOM and light DOM for a given node | ||
let cloneNode = (node, parent) => { | ||
let walkTree = (nextn, nextp) => { | ||
while (nextn) { | ||
if (!ignoreTags.includes(nextn.nodeName)) { | ||
cloneNode(nextn, nextp); | ||
try { | ||
let walkTree = (nextn, nextp) => { | ||
while (nextn) { | ||
if (!ignoreTags.includes(nextn.nodeName)) { | ||
cloneNode(nextn, nextp); | ||
} | ||
nextn = nextn.nextSibling; | ||
} | ||
nextn = nextn.nextSibling; | ||
} | ||
}; | ||
}; | ||
// mark the node before cloning | ||
markElement(node, disableShadowDOM); | ||
let clone = node.cloneNode(); | ||
// mark the node before cloning | ||
markElement(node, disableShadowDOM); | ||
let clone = node.cloneNode(); | ||
// We apply any element transformations here to avoid another treeWalk | ||
applyElementTransformations(clone); | ||
serializeBase64(clone, resources); | ||
parent.appendChild(clone); | ||
// We apply any element transformations here to avoid another treeWalk | ||
applyElementTransformations(clone); | ||
serializeBase64(clone, resources); | ||
parent.appendChild(clone); | ||
// shallow clone should not contain children | ||
if (clone.children) { | ||
Array.from(clone.children).forEach(child => clone.removeChild(child)); | ||
} | ||
// shallow clone should not contain children | ||
if (clone.children) { | ||
Array.from(clone.children).forEach(child => clone.removeChild(child)); | ||
} | ||
// clone shadow DOM | ||
if (node.shadowRoot && !disableShadowDOM) { | ||
// create shadowRoot | ||
if (clone.shadowRoot) { | ||
// it may be set up in a custom element's constructor | ||
clone.shadowRoot.innerHTML = ''; | ||
// clone shadow DOM | ||
if (node.shadowRoot && !disableShadowDOM) { | ||
// create shadowRoot | ||
if (clone.shadowRoot) { | ||
// it may be set up in a custom element's constructor | ||
clone.shadowRoot.innerHTML = ''; | ||
} else { | ||
clone.attachShadow({ | ||
mode: 'open' | ||
}); | ||
} | ||
// clone dom elements | ||
walkTree(node.shadowRoot.firstChild, clone.shadowRoot); | ||
} | ||
// clone light DOM | ||
walkTree(node.firstChild, clone); | ||
} catch (err) { | ||
if (!err.handled) { | ||
handleErrors(err, 'Error cloning node: ', node); | ||
} else { | ||
clone.attachShadow({ | ||
mode: 'open' | ||
}); | ||
throw err; | ||
} | ||
// clone dom elements | ||
walkTree(node.shadowRoot.firstChild, clone.shadowRoot); | ||
} | ||
// clone light DOM | ||
walkTree(node.firstChild, clone); | ||
}; | ||
@@ -618,2 +673,3 @@ let fragment = dom.createDocumentFragment(); | ||
html: serializeHTML(ctx), | ||
cookies: dom.cookie, | ||
warnings: Array.from(ctx.warnings), | ||
@@ -620,0 +676,0 @@ resources: Array.from(ctx.resources), |
{ | ||
"name": "@percy/dom", | ||
"version": "1.29.0-beta.0", | ||
"version": "1.29.0-beta.1", | ||
"license": "MIT", | ||
@@ -38,3 +38,3 @@ "repository": { | ||
}, | ||
"gitHead": "2d62f6ce1e9b7aa1430d186da00a2302884d92b2" | ||
"gitHead": "719cb9b3c5e50cfe23b2e27ebfa2410f30443cd8" | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
34496
672
241
4
12
1
73