@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.
Files changed (3) hide show
  1. package/README.md +37 -0
  2. package/dist/bundle.js +227 -25
  3. 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 = clone.createElement('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
- let img = clone.createElement('img');
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
- cloneEl.parentElement.insertBefore(img, cloneEl);
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.outerHTML;
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 = prepareDOM(dom);
302
- ctx.clone = ctx.dom.cloneNode(true);
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.17.0",
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": "b7b2403047ea6208de71c73232df8a5375781a30"
37
+ "gitHead": "1a95e827ef2fb7e6f2c7a7a5a90d265185f31fc2"
38
38
  }