@percy/dom 1.17.0 → 1.19.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/bundle.js +227 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -71,7 +71,44 @@ Videos without a `poster` attribute will have the current frame of the video
|
|
|
71
71
|
serialized into an image and set as the `poster` attribute automatically. This is
|
|
72
72
|
to ensure videos have a stable image to display when screenshots are captured.
|
|
73
73
|
|
|
74
|
+
### Shadow DOM
|
|
75
|
+
|
|
76
|
+
Shadow dom `#shadow-root (open)` is serialized into declarative shadow DOM (`<template shadowroot="open">`) form
|
|
77
|
+
Shadow host element is annotated with special identifier attribute named `data-percy-shadow-host`. This identifier
|
|
78
|
+
attribute may be used when passing `domTransformation`.
|
|
79
|
+
|
|
74
80
|
### Other elements
|
|
75
81
|
|
|
76
82
|
_All other elements are not serialized._ The resulting cloned document is passed to any provided
|
|
77
83
|
`domTransformation` option before the serialize function returns a DOM string.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
## Examples
|
|
88
|
+
|
|
89
|
+
1. perform DOM transformations on serialized DOM
|
|
90
|
+
|
|
91
|
+
this example contains scenario of nested shadow DOMs
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import serializeDOM from '@percy/dom';
|
|
95
|
+
|
|
96
|
+
const domSnapshot = serializeDOM({
|
|
97
|
+
domTransformation: (documentElement) => {
|
|
98
|
+
function insertHelloHeader(root) {
|
|
99
|
+
h1 = document.createElement('h1');
|
|
100
|
+
h1.innerText = 'Inserted using dom transformations';
|
|
101
|
+
root.append(h1);
|
|
102
|
+
|
|
103
|
+
root.querySelectorAll('[data-percy-shadow-host]')
|
|
104
|
+
.forEach(
|
|
105
|
+
shadowHost => {
|
|
106
|
+
if (shadowHost?.shadowRoot)
|
|
107
|
+
insertHelloHeader(shadowHost.shadowRoot)
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
}
|
|
111
|
+
insertHelloHeader(documentElement);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
```
|
package/dist/bundle.js
CHANGED
|
@@ -6,21 +6,6 @@
|
|
|
6
6
|
process.env = process.env || {};
|
|
7
7
|
process.env.__PERCY_BROWSERIFIED__ = true;
|
|
8
8
|
|
|
9
|
-
// Returns a mostly random uid.
|
|
10
|
-
function uid() {
|
|
11
|
-
return `_${Math.random().toString(36).substr(2, 9)}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// Marks elements that are to be serialized later with a data attribute.
|
|
15
|
-
function prepareDOM(dom) {
|
|
16
|
-
for (let elem of dom.querySelectorAll('input, textarea, select, iframe, canvas, video, style')) {
|
|
17
|
-
if (!elem.getAttribute('data-percy-element-id')) {
|
|
18
|
-
elem.setAttribute('data-percy-element-id', uid());
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return dom;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
9
|
// Translates JavaScript properties of inputs into DOM attributes.
|
|
25
10
|
function serializeInputElements(_ref) {
|
|
26
11
|
let {
|
|
@@ -54,6 +39,18 @@
|
|
|
54
39
|
cloneEl.setAttribute('value', elem.value);
|
|
55
40
|
}
|
|
56
41
|
}
|
|
42
|
+
|
|
43
|
+
// find inputs inside shadow host and recursively serialize them.
|
|
44
|
+
for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
|
|
45
|
+
let percyElementId = shadowHost.getAttribute('data-percy-element-id');
|
|
46
|
+
let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
47
|
+
if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
|
|
48
|
+
serializeInputElements({
|
|
49
|
+
dom: shadowHost.shadowRoot,
|
|
50
|
+
clone: cloneShadowHost.shadowRoot
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
// Adds a `<base>` element to the serialized iframe's `<head>`. This is necessary when
|
|
@@ -75,13 +72,14 @@
|
|
|
75
72
|
enableJavaScript
|
|
76
73
|
} = _ref;
|
|
77
74
|
for (let frame of dom.querySelectorAll('iframe')) {
|
|
75
|
+
var _clone$head;
|
|
78
76
|
let percyElementId = frame.getAttribute('data-percy-element-id');
|
|
79
77
|
let cloneEl = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
80
78
|
let builtWithJs = !frame.srcdoc && (!frame.src || frame.src.split(':')[0] === 'javascript');
|
|
81
79
|
|
|
82
80
|
// delete frames within the head since they usually break pages when
|
|
83
81
|
// rerendered and do not effect the visuals of a page
|
|
84
|
-
if (clone.head.contains(cloneEl)) {
|
|
82
|
+
if ((_clone$head = clone.head) !== null && _clone$head !== void 0 && _clone$head.contains(cloneEl)) {
|
|
85
83
|
cloneEl.remove();
|
|
86
84
|
|
|
87
85
|
// if the frame document is accessible and not empty, we can serialize it
|
|
@@ -114,6 +112,21 @@
|
|
|
114
112
|
cloneEl.remove();
|
|
115
113
|
}
|
|
116
114
|
}
|
|
115
|
+
|
|
116
|
+
// find iframes inside shadow host and recursively serialize them.
|
|
117
|
+
for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
|
|
118
|
+
let percyElementId = shadowHost.getAttribute('data-percy-element-id');
|
|
119
|
+
let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
120
|
+
if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
|
|
121
|
+
serializeFrames({
|
|
122
|
+
dom: shadowHost.shadowRoot,
|
|
123
|
+
clone: cloneShadowHost.shadowRoot,
|
|
124
|
+
warnings,
|
|
125
|
+
resources,
|
|
126
|
+
enableJavaScript
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
// Returns true if a stylesheet is a CSSOM-based stylesheet.
|
|
@@ -137,14 +150,20 @@
|
|
|
137
150
|
function serializeCSSOM(_ref) {
|
|
138
151
|
let {
|
|
139
152
|
dom,
|
|
140
|
-
clone
|
|
153
|
+
clone,
|
|
154
|
+
warnings
|
|
141
155
|
} = _ref;
|
|
142
156
|
for (let styleSheet of dom.styleSheets) {
|
|
143
157
|
if (isCSSOM(styleSheet)) {
|
|
144
158
|
let styleId = styleSheet.ownerNode.getAttribute('data-percy-element-id');
|
|
159
|
+
if (!styleId) {
|
|
160
|
+
let attributes = Array.from(styleSheet.ownerNode.attributes).map(attr => `${attr.name}: ${attr.value}`);
|
|
161
|
+
warnings.add(`stylesheet with attributes - [ ${attributes} ] - was not serialized`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
145
164
|
let cloneOwnerNode = clone.querySelector(`[data-percy-element-id="${styleId}"]`);
|
|
146
165
|
if (styleSheetsMatch(styleSheet, cloneOwnerNode.sheet)) continue;
|
|
147
|
-
let style =
|
|
166
|
+
let style = document.createElement('style');
|
|
148
167
|
style.type = 'text/css';
|
|
149
168
|
style.setAttribute('data-percy-element-id', styleId);
|
|
150
169
|
style.setAttribute('data-percy-cssom-serialized', 'true');
|
|
@@ -153,6 +172,18 @@
|
|
|
153
172
|
cloneOwnerNode.remove();
|
|
154
173
|
}
|
|
155
174
|
}
|
|
175
|
+
|
|
176
|
+
// find stylesheets inside shadow host and recursively serialize them.
|
|
177
|
+
for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
|
|
178
|
+
let percyElementId = shadowHost.getAttribute('data-percy-element-id');
|
|
179
|
+
let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
180
|
+
if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
|
|
181
|
+
serializeCSSOM({
|
|
182
|
+
dom: shadowHost.shadowRoot,
|
|
183
|
+
clone: cloneShadowHost.shadowRoot
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
156
187
|
}
|
|
157
188
|
|
|
158
189
|
// Creates a resource object from an element's unique ID and data URL
|
|
@@ -197,7 +228,8 @@
|
|
|
197
228
|
resources.add(resource);
|
|
198
229
|
|
|
199
230
|
// create an image element in the cloned dom
|
|
200
|
-
|
|
231
|
+
// TODO: this works, verify if this is fine?
|
|
232
|
+
let img = document.createElement('img');
|
|
201
233
|
// use a data attribute to avoid making a real request
|
|
202
234
|
img.setAttribute('data-percy-serialized-attribute-src', resource.url);
|
|
203
235
|
|
|
@@ -217,9 +249,27 @@
|
|
|
217
249
|
|
|
218
250
|
// insert the image into the cloned DOM and remove the cloned canvas element
|
|
219
251
|
let cloneEl = clone.querySelector(`[data-percy-element-id=${percyElementId}]`);
|
|
220
|
-
|
|
252
|
+
// `parentElement` for elements directly under shadow root is `null` -> Incase of Nested Shadow DOM.
|
|
253
|
+
if (cloneEl.parentElement) {
|
|
254
|
+
cloneEl.parentElement.insertBefore(img, cloneEl);
|
|
255
|
+
} else {
|
|
256
|
+
clone.insertBefore(img, cloneEl);
|
|
257
|
+
}
|
|
221
258
|
cloneEl.remove();
|
|
222
259
|
}
|
|
260
|
+
|
|
261
|
+
// find canvas inside shadow host and recursively serialize them.
|
|
262
|
+
for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
|
|
263
|
+
let percyElementId = shadowHost.getAttribute('data-percy-element-id');
|
|
264
|
+
let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
265
|
+
if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
|
|
266
|
+
serializeCanvas({
|
|
267
|
+
dom: shadowHost.shadowRoot,
|
|
268
|
+
clone: cloneShadowHost.shadowRoot,
|
|
269
|
+
resources
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
223
273
|
}
|
|
224
274
|
|
|
225
275
|
// Captures the current frame of videos and sets the poster image
|
|
@@ -227,7 +277,8 @@
|
|
|
227
277
|
let {
|
|
228
278
|
dom,
|
|
229
279
|
clone,
|
|
230
|
-
resources
|
|
280
|
+
resources,
|
|
281
|
+
warnings
|
|
231
282
|
} = _ref;
|
|
232
283
|
for (let video of dom.querySelectorAll('video')) {
|
|
233
284
|
// if the video already has a poster image, no work for us to do
|
|
@@ -241,7 +292,9 @@
|
|
|
241
292
|
canvas.getContext('2d').drawImage(video, 0, 0, width, height);
|
|
242
293
|
try {
|
|
243
294
|
dataUrl = canvas.toDataURL();
|
|
244
|
-
} catch {
|
|
295
|
+
} catch (e) {
|
|
296
|
+
warnings.add(`data-percy-element-id="${videoId}" : ${e.toString()}`);
|
|
297
|
+
}
|
|
245
298
|
|
|
246
299
|
// if the canvas produces a blank image, skip
|
|
247
300
|
if (!dataUrl || dataUrl === 'data:,') continue;
|
|
@@ -253,6 +306,154 @@
|
|
|
253
306
|
// use a data attribute to avoid making a real request
|
|
254
307
|
cloneEl.setAttribute('data-percy-serialized-attribute-poster', resource.url);
|
|
255
308
|
}
|
|
309
|
+
|
|
310
|
+
// find video inside shadow host and recursively serialize them.
|
|
311
|
+
for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
|
|
312
|
+
let percyElementId = shadowHost.getAttribute('data-percy-element-id');
|
|
313
|
+
let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
|
|
314
|
+
if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
|
|
315
|
+
serializeVideos({
|
|
316
|
+
dom: shadowHost.shadowRoot,
|
|
317
|
+
clone: cloneShadowHost.shadowRoot,
|
|
318
|
+
resources,
|
|
319
|
+
warnings
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Returns a mostly random uid.
|
|
326
|
+
function uid() {
|
|
327
|
+
return `_${Math.random().toString(36).substr(2, 9)}`;
|
|
328
|
+
}
|
|
329
|
+
function markElement(domElement) {
|
|
330
|
+
var _domElement$tagName;
|
|
331
|
+
// Mark elements that are to be serialized later with a data attribute.
|
|
332
|
+
if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase())) {
|
|
333
|
+
if (!domElement.getAttribute('data-percy-element-id')) {
|
|
334
|
+
domElement.setAttribute('data-percy-element-id', uid());
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// add special marker for shadow host
|
|
339
|
+
if (domElement.shadowRoot) {
|
|
340
|
+
if (!domElement.getAttribute('data-percy-shadow-host')) {
|
|
341
|
+
domElement.setAttribute('data-percy-shadow-host', '');
|
|
342
|
+
}
|
|
343
|
+
if (!domElement.getAttribute('data-percy-element-id')) {
|
|
344
|
+
domElement.setAttribute('data-percy-element-id', uid());
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Custom deep clone function that replaces Percy's current clone behavior.
|
|
351
|
+
* This enables us to capture shadow DOM in snapshots. It takes advantage of `attachShadow`'s mode option set to open
|
|
352
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#parameters
|
|
353
|
+
*/
|
|
354
|
+
|
|
355
|
+
// returns document fragment
|
|
356
|
+
const deepClone = host => {
|
|
357
|
+
// clones shadow DOM and light DOM for a given node
|
|
358
|
+
let cloneNode = (node, parent) => {
|
|
359
|
+
let walkTree = (nextn, nextp) => {
|
|
360
|
+
while (nextn) {
|
|
361
|
+
cloneNode(nextn, nextp);
|
|
362
|
+
nextn = nextn.nextSibling;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// mark the node before cloning
|
|
367
|
+
markElement(node);
|
|
368
|
+
let clone = node.cloneNode();
|
|
369
|
+
parent.appendChild(clone);
|
|
370
|
+
|
|
371
|
+
// clone shadow DOM
|
|
372
|
+
if (node.shadowRoot) {
|
|
373
|
+
// create shadowRoot
|
|
374
|
+
if (clone.shadowRoot) {
|
|
375
|
+
// it may be set up in a custom element's constructor
|
|
376
|
+
clone.shadowRoot.innerHTML = '';
|
|
377
|
+
} else {
|
|
378
|
+
clone.attachShadow({
|
|
379
|
+
mode: 'open'
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// clone stylesheets in shadowRoot
|
|
384
|
+
for (let sheet of node.shadowRoot.adoptedStyleSheets) {
|
|
385
|
+
let cssText = Array.from(sheet.rules).map(rule => rule.cssText).join('\n');
|
|
386
|
+
let style = document.createElement('style');
|
|
387
|
+
style.appendChild(document.createTextNode(cssText));
|
|
388
|
+
clone.shadowRoot.prepend(style);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// clone dom elements
|
|
392
|
+
walkTree(node.shadowRoot.firstChild, clone.shadowRoot);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// clone light DOM
|
|
396
|
+
walkTree(node.firstChild, clone);
|
|
397
|
+
};
|
|
398
|
+
let fragment = document.createDocumentFragment();
|
|
399
|
+
cloneNode(host, fragment);
|
|
400
|
+
return fragment;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Deep clone a document while also preserving shadow roots and converting adoptedStylesheets to <style> tags.
|
|
405
|
+
*/
|
|
406
|
+
const cloneNodeAndShadow = doc => {
|
|
407
|
+
let mockDocumentFragment = deepClone(doc.documentElement);
|
|
408
|
+
// convert document fragment to document object
|
|
409
|
+
let cloneDocument = doc.cloneNode();
|
|
410
|
+
// dissolve document fragment in clone document
|
|
411
|
+
cloneDocument.appendChild(mockDocumentFragment);
|
|
412
|
+
return cloneDocument;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Use `getInnerHTML()` to serialize shadow dom as <template> tags. `innerHTML` and `outerHTML` don't do this. Buzzword: "declarative shadow dom"
|
|
417
|
+
*/
|
|
418
|
+
const getOuterHTML = docElement => {
|
|
419
|
+
// firefox doesn't serialize shadow DOM, we're awaiting API's by firefox to become ready and are not polyfilling it.
|
|
420
|
+
if (!docElement.getInnerHTML) {
|
|
421
|
+
return docElement.outerHTML;
|
|
422
|
+
}
|
|
423
|
+
// chromium gives us declarative shadow DOM serialization API
|
|
424
|
+
let innerHTML = docElement.getInnerHTML({
|
|
425
|
+
includeShadowRoots: true
|
|
426
|
+
});
|
|
427
|
+
docElement.textContent = '';
|
|
428
|
+
return docElement.outerHTML.replace('</html>', `${innerHTML}</html>`);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// we inject declarative shadow dom polyfill to allow shadow dom to load in non chromium infrastructure browsers
|
|
432
|
+
// Since only chromium currently supports declarative shadow DOM - https://caniuse.com/declarative-shadow-dom
|
|
433
|
+
function injectDeclarativeShadowDOMPolyfill(ctx) {
|
|
434
|
+
let clone = ctx.clone;
|
|
435
|
+
let scriptEl = clone.createElement('script');
|
|
436
|
+
scriptEl.setAttribute('id', '__percy_shadowdom_helper');
|
|
437
|
+
scriptEl.setAttribute('data-percy-injected', true);
|
|
438
|
+
scriptEl.innerHTML = `
|
|
439
|
+
function reversePolyFill(root=document){
|
|
440
|
+
root.querySelectorAll('template[shadowroot]').forEach(template => {
|
|
441
|
+
const mode = template.getAttribute('shadowroot');
|
|
442
|
+
const shadowRoot = template.parentNode.attachShadow({ mode });
|
|
443
|
+
shadowRoot.appendChild(template.content);
|
|
444
|
+
template.remove();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
root.querySelectorAll('[data-percy-shadow-host]').forEach(shadowHost => reversePolyFill(shadowHost.shadowRoot));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (["interactive", "complete"].includes(document.readyState)) {
|
|
451
|
+
reversePolyFill();
|
|
452
|
+
} else {
|
|
453
|
+
document.addEventListener("DOMContentLoaded", () => reversePolyFill());
|
|
454
|
+
}
|
|
455
|
+
`.replace(/(\n|\s{2}|\t)/g, '');
|
|
456
|
+
clone.body.appendChild(scriptEl);
|
|
256
457
|
}
|
|
257
458
|
|
|
258
459
|
// Returns a copy or new doctype for a document.
|
|
@@ -275,7 +476,7 @@
|
|
|
275
476
|
|
|
276
477
|
// Serializes and returns the cloned DOM as an HTML string
|
|
277
478
|
function serializeHTML(ctx) {
|
|
278
|
-
let html = ctx.clone.documentElement
|
|
479
|
+
let html = getOuterHTML(ctx.clone.documentElement);
|
|
279
480
|
// replace serialized data attributes with real attributes
|
|
280
481
|
html = html.replace(/ data-percy-serialized-attribute-(\w+?)=/ig, ' $1=');
|
|
281
482
|
// include the doctype with the html string
|
|
@@ -298,8 +499,8 @@
|
|
|
298
499
|
warnings: new Set(),
|
|
299
500
|
enableJavaScript
|
|
300
501
|
};
|
|
301
|
-
ctx.dom =
|
|
302
|
-
ctx.clone = ctx.dom
|
|
502
|
+
ctx.dom = dom;
|
|
503
|
+
ctx.clone = cloneNodeAndShadow(ctx.dom);
|
|
303
504
|
serializeInputElements(ctx);
|
|
304
505
|
serializeFrames(ctx);
|
|
305
506
|
serializeVideos(ctx);
|
|
@@ -314,6 +515,7 @@
|
|
|
314
515
|
console.error('Could not transform the dom:', err.message);
|
|
315
516
|
}
|
|
316
517
|
}
|
|
518
|
+
injectDeclarativeShadowDOMPolyfill(ctx);
|
|
317
519
|
let result = {
|
|
318
520
|
html: serializeHTML(ctx),
|
|
319
521
|
warnings: Array.from(ctx.warnings),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/dom",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.1-alpha.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"interactor.js": "^2.0.0-beta.10"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "1a95e827ef2fb7e6f2c7a7a5a90d265185f31fc2"
|
|
38
38
|
}
|