@percy/dom 1.16.0 → 2.0.0-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 (2) hide show
  1. package/dist/bundle.js +220 -24
  2. package/package.json +2 -2
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.
@@ -144,7 +157,7 @@
144
157
  let styleId = styleSheet.ownerNode.getAttribute('data-percy-element-id');
145
158
  let cloneOwnerNode = clone.querySelector(`[data-percy-element-id="${styleId}"]`);
146
159
  if (styleSheetsMatch(styleSheet, cloneOwnerNode.sheet)) continue;
147
- let style = clone.createElement('style');
160
+ let style = document.createElement('style');
148
161
  style.type = 'text/css';
149
162
  style.setAttribute('data-percy-element-id', styleId);
150
163
  style.setAttribute('data-percy-cssom-serialized', 'true');
@@ -153,6 +166,18 @@
153
166
  cloneOwnerNode.remove();
154
167
  }
155
168
  }
169
+
170
+ // find stylesheets inside shadow host and recursively serialize them.
171
+ for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
172
+ let percyElementId = shadowHost.getAttribute('data-percy-element-id');
173
+ let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
174
+ if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
175
+ serializeCSSOM({
176
+ dom: shadowHost.shadowRoot,
177
+ clone: cloneShadowHost.shadowRoot
178
+ });
179
+ }
180
+ }
156
181
  }
157
182
 
158
183
  // Creates a resource object from an element's unique ID and data URL
@@ -197,7 +222,8 @@
197
222
  resources.add(resource);
198
223
 
199
224
  // create an image element in the cloned dom
200
- let img = clone.createElement('img');
225
+ // TODO: this works, verify if this is fine?
226
+ let img = document.createElement('img');
201
227
  // use a data attribute to avoid making a real request
202
228
  img.setAttribute('data-percy-serialized-attribute-src', resource.url);
203
229
 
@@ -217,9 +243,27 @@
217
243
 
218
244
  // insert the image into the cloned DOM and remove the cloned canvas element
219
245
  let cloneEl = clone.querySelector(`[data-percy-element-id=${percyElementId}]`);
220
- cloneEl.parentElement.insertBefore(img, cloneEl);
246
+ // `parentElement` for elements directly under shadow root is `null` -> Incase of Nested Shadow DOM.
247
+ if (cloneEl.parentElement) {
248
+ cloneEl.parentElement.insertBefore(img, cloneEl);
249
+ } else {
250
+ clone.insertBefore(img, cloneEl);
251
+ }
221
252
  cloneEl.remove();
222
253
  }
254
+
255
+ // find canvas inside shadow host and recursively serialize them.
256
+ for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
257
+ let percyElementId = shadowHost.getAttribute('data-percy-element-id');
258
+ let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
259
+ if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
260
+ serializeCanvas({
261
+ dom: shadowHost.shadowRoot,
262
+ clone: cloneShadowHost.shadowRoot,
263
+ resources
264
+ });
265
+ }
266
+ }
223
267
  }
224
268
 
225
269
  // Captures the current frame of videos and sets the poster image
@@ -227,7 +271,8 @@
227
271
  let {
228
272
  dom,
229
273
  clone,
230
- resources
274
+ resources,
275
+ warnings
231
276
  } = _ref;
232
277
  for (let video of dom.querySelectorAll('video')) {
233
278
  // if the video already has a poster image, no work for us to do
@@ -241,7 +286,9 @@
241
286
  canvas.getContext('2d').drawImage(video, 0, 0, width, height);
242
287
  try {
243
288
  dataUrl = canvas.toDataURL();
244
- } catch {}
289
+ } catch (e) {
290
+ warnings.add(`data-percy-element-id="${videoId}" : ${e.toString()}`);
291
+ }
245
292
 
246
293
  // if the canvas produces a blank image, skip
247
294
  if (!dataUrl || dataUrl === 'data:,') continue;
@@ -253,6 +300,154 @@
253
300
  // use a data attribute to avoid making a real request
254
301
  cloneEl.setAttribute('data-percy-serialized-attribute-poster', resource.url);
255
302
  }
303
+
304
+ // find video inside shadow host and recursively serialize them.
305
+ for (let shadowHost of dom.querySelectorAll('[data-percy-shadow-host]')) {
306
+ let percyElementId = shadowHost.getAttribute('data-percy-element-id');
307
+ let cloneShadowHost = clone.querySelector(`[data-percy-element-id="${percyElementId}"]`);
308
+ if (shadowHost.shadowRoot && cloneShadowHost.shadowRoot) {
309
+ serializeVideos({
310
+ dom: shadowHost.shadowRoot,
311
+ clone: cloneShadowHost.shadowRoot,
312
+ resources,
313
+ warnings
314
+ });
315
+ }
316
+ }
317
+ }
318
+
319
+ // Returns a mostly random uid.
320
+ function uid() {
321
+ return `_${Math.random().toString(36).substr(2, 9)}`;
322
+ }
323
+ function markElement(domElement) {
324
+ var _domElement$tagName;
325
+ // Mark elements that are to be serialized later with a data attribute.
326
+ if (['input', 'textarea', 'select', 'iframe', 'canvas', 'video', 'style'].includes((_domElement$tagName = domElement.tagName) === null || _domElement$tagName === void 0 ? void 0 : _domElement$tagName.toLowerCase())) {
327
+ if (!domElement.getAttribute('data-percy-element-id')) {
328
+ domElement.setAttribute('data-percy-element-id', uid());
329
+ }
330
+ }
331
+
332
+ // add special marker for shadow host
333
+ if (domElement.shadowRoot) {
334
+ if (!domElement.getAttribute('data-percy-shadow-host')) {
335
+ domElement.setAttribute('data-percy-shadow-host', '');
336
+ }
337
+ if (!domElement.getAttribute('data-percy-element-id')) {
338
+ domElement.setAttribute('data-percy-element-id', uid());
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Custom deep clone function that replaces Percy's current clone behavior.
345
+ * This enables us to capture shadow DOM in snapshots. It takes advantage of `attachShadow`'s mode option set to open
346
+ * https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#parameters
347
+ */
348
+
349
+ // returns document fragment
350
+ const deepClone = host => {
351
+ // clones shadow DOM and light DOM for a given node
352
+ let cloneNode = (node, parent) => {
353
+ let walkTree = (nextn, nextp) => {
354
+ while (nextn) {
355
+ cloneNode(nextn, nextp);
356
+ nextn = nextn.nextSibling;
357
+ }
358
+ };
359
+
360
+ // mark the node before cloning
361
+ markElement(node);
362
+ let clone = node.cloneNode();
363
+ parent.appendChild(clone);
364
+
365
+ // clone shadow DOM
366
+ if (node.shadowRoot) {
367
+ // create shadowRoot
368
+ if (clone.shadowRoot) {
369
+ // it may be set up in a custom element's constructor
370
+ clone.shadowRoot.innerHTML = '';
371
+ } else {
372
+ clone.attachShadow({
373
+ mode: 'open'
374
+ });
375
+ }
376
+
377
+ // clone stylesheets in shadowRoot
378
+ for (let sheet of node.shadowRoot.adoptedStyleSheets) {
379
+ let cssText = Array.from(sheet.rules).map(rule => rule.cssText).join('\n');
380
+ let style = document.createElement('style');
381
+ style.appendChild(document.createTextNode(cssText));
382
+ clone.shadowRoot.prepend(style);
383
+ }
384
+
385
+ // clone dom elements
386
+ walkTree(node.shadowRoot.firstChild, clone.shadowRoot);
387
+ }
388
+
389
+ // clone light DOM
390
+ walkTree(node.firstChild, clone);
391
+ };
392
+ let fragment = document.createDocumentFragment();
393
+ cloneNode(host, fragment);
394
+ return fragment;
395
+ };
396
+
397
+ /**
398
+ * Deep clone a document while also preserving shadow roots and converting adoptedStylesheets to <style> tags.
399
+ */
400
+ const cloneNodeAndShadow = doc => {
401
+ let mockDocumentFragment = deepClone(doc.documentElement);
402
+ // convert document fragment to document object
403
+ let cloneDocument = doc.cloneNode();
404
+ // dissolve document fragment in clone document
405
+ cloneDocument.appendChild(mockDocumentFragment);
406
+ return cloneDocument;
407
+ };
408
+
409
+ /**
410
+ * Use `getInnerHTML()` to serialize shadow dom as <template> tags. `innerHTML` and `outerHTML` don't do this. Buzzword: "declarative shadow dom"
411
+ */
412
+ const getOuterHTML = docElement => {
413
+ // firefox doesn't serialize shadow DOM, we're awaiting API's by firefox to become ready and are not polyfilling it.
414
+ if (!docElement.getInnerHTML) {
415
+ return docElement.outerHTML;
416
+ }
417
+ // chromium gives us declarative shadow DOM serialization API
418
+ let innerHTML = docElement.getInnerHTML({
419
+ includeShadowRoots: true
420
+ });
421
+ docElement.textContent = '';
422
+ return docElement.outerHTML.replace('</html>', `${innerHTML}</html>`);
423
+ };
424
+
425
+ // we inject declarative shadow dom polyfill to allow shadow dom to load in non chromium infrastructure browsers
426
+ // Since only chromium currently supports declarative shadow DOM - https://caniuse.com/declarative-shadow-dom
427
+ function injectDeclarativeShadowDOMPolyfill(ctx) {
428
+ let clone = ctx.clone;
429
+ let scriptEl = clone.createElement('script');
430
+ scriptEl.setAttribute('id', '__percy_shadowdom_helper');
431
+ scriptEl.setAttribute('data-percy-injected', true);
432
+ scriptEl.innerHTML = `
433
+ function reversePolyFill(root=document){
434
+ root.querySelectorAll('template[shadowroot]').forEach(template => {
435
+ const mode = template.getAttribute('shadowroot');
436
+ const shadowRoot = template.parentNode.attachShadow({ mode });
437
+ shadowRoot.appendChild(template.content);
438
+ template.remove();
439
+ });
440
+
441
+ root.querySelectorAll('[data-percy-shadow-host]').forEach(shadowHost => reversePolyFill(shadowHost.shadowRoot));
442
+ }
443
+
444
+ if (["interactive", "complete"].includes(document.readyState)) {
445
+ reversePolyFill();
446
+ } else {
447
+ document.addEventListener("DOMContentLoaded", () => reversePolyFill());
448
+ }
449
+ `.replace(/(\n|\s{2}|\t)/g, '');
450
+ clone.body.appendChild(scriptEl);
256
451
  }
257
452
 
258
453
  // Returns a copy or new doctype for a document.
@@ -275,7 +470,7 @@
275
470
 
276
471
  // Serializes and returns the cloned DOM as an HTML string
277
472
  function serializeHTML(ctx) {
278
- let html = ctx.clone.documentElement.outerHTML;
473
+ let html = getOuterHTML(ctx.clone.documentElement);
279
474
  // replace serialized data attributes with real attributes
280
475
  html = html.replace(/ data-percy-serialized-attribute-(\w+?)=/ig, ' $1=');
281
476
  // include the doctype with the html string
@@ -298,8 +493,8 @@
298
493
  warnings: new Set(),
299
494
  enableJavaScript
300
495
  };
301
- ctx.dom = prepareDOM(dom);
302
- ctx.clone = ctx.dom.cloneNode(true);
496
+ ctx.dom = dom;
497
+ ctx.clone = cloneNodeAndShadow(ctx.dom);
303
498
  serializeInputElements(ctx);
304
499
  serializeFrames(ctx);
305
500
  serializeVideos(ctx);
@@ -314,6 +509,7 @@
314
509
  console.error('Could not transform the dom:', err.message);
315
510
  }
316
511
  }
512
+ injectDeclarativeShadowDOMPolyfill(ctx);
317
513
  let result = {
318
514
  html: serializeHTML(ctx),
319
515
  warnings: Array.from(ctx.warnings),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/dom",
3
- "version": "1.16.0",
3
+ "version": "2.0.0-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": "147d3a6f249771252ee6cf03c9af44e4e21e1c55"
37
+ "gitHead": "6ebc3d59194d6cd25fceb5366ab9f230849a4f41"
38
38
  }