diff --git a/src/percy-agent-client/dom.ts b/src/percy-agent-client/dom.ts index b5eb218e..21ab9769 100644 --- a/src/percy-agent-client/dom.ts +++ b/src/percy-agent-client/dom.ts @@ -95,9 +95,10 @@ class DOM { this.serializeInputElements(clonedDOM) this.serializeFrameElements(clonedDOM) - // We only want to serialize the CSSOM if JS isn't enabled. + // We only want to serialize the CSSOM or canvas if JS isn't enabled. if (!this.options.enableJavaScript) { this.serializeCSSOM(clonedDOM) + this.serializeCanvasElements(clonedDOM) } return clonedDOM.documentElement @@ -225,6 +226,36 @@ class DOM { }) } + /** + * Capture in-memory canvas elements & serialize them to images into the + * cloned DOM. + * + * Without this, applications that have canvas elements will be missing and + * appear broken. The Canvas DOM API allows you to covert them to images, which + * is what we're doing here to capture that in-memory state & serialize it + * into the DOM Percy captures. + * + * It's important to note the `.toDataURL` API requires WebGL canvas elements + * to use `preserveDrawingBuffer: true`. This is because `.toDataURL` captures + * from the drawing buffer, which is cleared after each render by default for + * performance. + * + */ + private serializeCanvasElements(clonedDOM: HTMLDocument): void { + for (const $canvas of this.originalDOM.querySelectorAll('canvas')) { + const $image = clonedDOM.createElement('img') + const canvasId = $canvas.getAttribute('data-percy-element-id') + const $clonedCanvas = clonedDOM.querySelector(`[data-percy-element-id=${canvasId}]`) as any + + $image.setAttribute('style', 'max-width: 100%') + $image.classList.add('percy-canvas-image') + + $image.src = $canvas.toDataURL() + $image.setAttribute('data-percy-canvas-serialized', 'true') + $clonedCanvas.parentElement.insertBefore($image, $clonedCanvas) + $clonedCanvas.remove() + } + } /** * A single place to mutate the original DOM. This should be the last resort! * This will change the customer's DOM and have a possible impact on the @@ -235,7 +266,8 @@ class DOM { const createUID = () => `_${Math.random().toString(36).substr(2, 9)}` const formNodes = this.originalDOM.querySelectorAll(FORM_ELEMENTS_SELECTOR) const frameNodes = this.originalDOM.querySelectorAll('iframe') - const elements = [...formNodes, ...frameNodes] as HTMLElement[] + const canvasNodes = this.originalDOM.querySelectorAll('canvas') + const elements = [...formNodes, ...frameNodes, ...canvasNodes] as HTMLElement[] // loop through each element and apply an ID for serialization later elements.forEach((elem) => { diff --git a/test/integration/agent-integration.test.ts b/test/integration/agent-integration.test.ts index 91233a89..2c129c2f 100644 --- a/test/integration/agent-integration.test.ts +++ b/test/integration/agent-integration.test.ts @@ -233,5 +233,29 @@ describe('Integration test', () => { }) }) + + describe('canvas', () => { + it('captures canvas elements', async () => { + await page.goto(`http://localhost:${PORT}/serialize-canvas.html`) + await page.waitFor('#webgl canvas') + // I cannot think of a nicer way to let the canvas animations/drawing settle + // so sadly, use a timeout + await page.waitFor(1000) + const domSnapshot = await snapshot(page, 'Canvas elements') + const $ = cheerio.load(domSnapshot) + + expect($('[data-percy-canvas-serialized]').length).to.equal(2) + }) + + it("doesn't serialize with JS enabled", async () => { + await page.goto(`http://localhost:${PORT}/serialize-canvas.html`) + await page.waitFor('#webgl canvas') + await page.waitFor(1000) + const domSnapshot = await snapshot(page, 'Canvas elements -- with JS', { enableJavaScript: true }) + const $ = cheerio.load(domSnapshot) + + expect($('[data-percy-canvas-serialized]').length).to.equal(0) + }) + }) }) }) diff --git a/test/integration/testcases/canvas/charts.js b/test/integration/testcases/canvas/charts.js new file mode 100644 index 00000000..d19f654d --- /dev/null +++ b/test/integration/testcases/canvas/charts.js @@ -0,0 +1,52 @@ +// Taken from: +// https://github.com/chartjs/Chart.js/blob/master/samples/charts/doughnut.html +window.chartColors = { + red: 'rgb(255, 99, 132)', + orange: 'rgb(255, 159, 64)', + yellow: 'rgb(255, 205, 86)', + green: 'rgb(75, 192, 192)', + blue: 'rgb(54, 162, 235)', + purple: 'rgb(153, 102, 255)', + grey: 'rgb(201, 203, 207)' +}; + +let config = { + type: 'doughnut', + data: { + datasets: [ + { + data: ['22', '12', '67', '18', '33'], + backgroundColor: [ + window.chartColors.red, + window.chartColors.orange, + window.chartColors.yellow, + window.chartColors.green, + window.chartColors.blue + ], + label: 'Dataset 1' + } + ], + labels: ['Red', 'Orange', 'Yellow', 'Green', 'Blue'] + }, + options: { + responsive: true, + legend: { + position: 'top' + }, + title: { + display: true, + text: 'Chart.js Doughnut Chart' + }, + // Helps make tests more determistic + animation: { + animateScale: false, + animateRotate: false + } + } +}; + +window.onload = function() { + let ctx = document.querySelector('#graphs').getContext('2d'); + + window.myDoughnut = new Chart(ctx, config); +}; diff --git a/test/integration/testcases/canvas/webgl.js b/test/integration/testcases/canvas/webgl.js new file mode 100644 index 00000000..1dfadc10 --- /dev/null +++ b/test/integration/testcases/canvas/webgl.js @@ -0,0 +1,378 @@ +// Taken from: +// https://github.com/mrdoob/three.js/blob/7a3d9d99dcc1dfd35ef9638fe51a91d9b7449043/examples/webgl_geometry_shapes.html +let container, camera, scene, renderer, group; + +let targetRotation = 0; +let targetRotationOnMouseDown = 0; +let mouseX = 0; +let mouseXOnMouseDown = 0; +let windowHalfX = window.innerWidth / 2; + +init(); +animate(); + +function init() { + container = document.querySelector('#webgl'); + + scene = new THREE.Scene(); + scene.background = new THREE.Color(0xf0f0f0); + + camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 1000); + camera.position.set(0, 150, 500); + scene.add(camera); + + var light = new THREE.PointLight(0xffffff, 0.8); + camera.add(light); + + group = new THREE.Group(); + group.position.y = 50; + scene.add(group); + + var loader = new THREE.TextureLoader(); + var texture = loader.load('textures/uv_grid_opengl.jpg'); + + // it's necessary to apply these settings in order to correctly display the texture on a shape geometry + + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(0.008, 0.008); + + function addShape(shape, extrudeSettings, color, x, y, z, rx, ry, rz, s) { + // flat shape with texture + // note: default UVs generated by THREE.ShapeBufferGeometry are simply the x- and y-coordinates of the vertices + + var geometry = new THREE.ShapeBufferGeometry(shape); + + var mesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ side: THREE.DoubleSide, map: texture })); + mesh.position.set(x, y, z - 175); + mesh.rotation.set(rx, ry, rz); + mesh.scale.set(s, s, s); + group.add(mesh); + + // flat shape + + var geometry = new THREE.ShapeBufferGeometry(shape); + + var mesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: color, side: THREE.DoubleSide })); + mesh.position.set(x, y, z - 125); + mesh.rotation.set(rx, ry, rz); + mesh.scale.set(s, s, s); + group.add(mesh); + + // extruded shape + + var geometry = new THREE.ExtrudeBufferGeometry(shape, extrudeSettings); + + var mesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: color })); + mesh.position.set(x, y, z - 75); + mesh.rotation.set(rx, ry, rz); + mesh.scale.set(s, s, s); + group.add(mesh); + + addLineShape(shape, color, x, y, z, rx, ry, rz, s); + } + + function addLineShape(shape, color, x, y, z, rx, ry, rz, s) { + // lines + + shape.autoClose = true; + + var points = shape.getPoints(); + var spacedPoints = shape.getSpacedPoints(50); + + var geometryPoints = new THREE.BufferGeometry().setFromPoints(points); + var geometrySpacedPoints = new THREE.BufferGeometry().setFromPoints(spacedPoints); + + // solid line + + var line = new THREE.Line(geometryPoints, new THREE.LineBasicMaterial({ color: color })); + line.position.set(x, y, z - 25); + line.rotation.set(rx, ry, rz); + line.scale.set(s, s, s); + group.add(line); + + // line from equidistance sampled points + + var line = new THREE.Line(geometrySpacedPoints, new THREE.LineBasicMaterial({ color: color })); + line.position.set(x, y, z + 25); + line.rotation.set(rx, ry, rz); + line.scale.set(s, s, s); + group.add(line); + + // vertices from real points + + var particles = new THREE.Points(geometryPoints, new THREE.PointsMaterial({ color: color, size: 4 })); + particles.position.set(x, y, z + 75); + particles.rotation.set(rx, ry, rz); + particles.scale.set(s, s, s); + group.add(particles); + + // equidistance sampled points + + var particles = new THREE.Points(geometrySpacedPoints, new THREE.PointsMaterial({ color: color, size: 4 })); + particles.position.set(x, y, z + 125); + particles.rotation.set(rx, ry, rz); + particles.scale.set(s, s, s); + group.add(particles); + } + + // California + + var californiaPts = []; + + californiaPts.push(new THREE.Vector2(610, 320)); + californiaPts.push(new THREE.Vector2(450, 300)); + californiaPts.push(new THREE.Vector2(392, 392)); + californiaPts.push(new THREE.Vector2(266, 438)); + californiaPts.push(new THREE.Vector2(190, 570)); + californiaPts.push(new THREE.Vector2(190, 600)); + californiaPts.push(new THREE.Vector2(160, 620)); + californiaPts.push(new THREE.Vector2(160, 650)); + californiaPts.push(new THREE.Vector2(180, 640)); + californiaPts.push(new THREE.Vector2(165, 680)); + californiaPts.push(new THREE.Vector2(150, 670)); + californiaPts.push(new THREE.Vector2(90, 737)); + californiaPts.push(new THREE.Vector2(80, 795)); + californiaPts.push(new THREE.Vector2(50, 835)); + californiaPts.push(new THREE.Vector2(64, 870)); + californiaPts.push(new THREE.Vector2(60, 945)); + californiaPts.push(new THREE.Vector2(300, 945)); + californiaPts.push(new THREE.Vector2(300, 743)); + californiaPts.push(new THREE.Vector2(600, 473)); + californiaPts.push(new THREE.Vector2(626, 425)); + californiaPts.push(new THREE.Vector2(600, 370)); + californiaPts.push(new THREE.Vector2(610, 320)); + + for (var i = 0; i < californiaPts.length; i++) californiaPts[i].multiplyScalar(0.25); + + var californiaShape = new THREE.Shape(californiaPts); + + // Triangle + + var triangleShape = new THREE.Shape() + .moveTo(80, 20) + .lineTo(40, 80) + .lineTo(120, 80) + .lineTo(80, 20); // close path + + // Heart + + var x = 0, + y = 0; + + var heartShape = new THREE.Shape() // From http://blog.burlock.org/html5/130-paths + .moveTo(x + 25, y + 25) + .bezierCurveTo(x + 25, y + 25, x + 20, y, x, y) + .bezierCurveTo(x - 30, y, x - 30, y + 35, x - 30, y + 35) + .bezierCurveTo(x - 30, y + 55, x - 10, y + 77, x + 25, y + 95) + .bezierCurveTo(x + 60, y + 77, x + 80, y + 55, x + 80, y + 35) + .bezierCurveTo(x + 80, y + 35, x + 80, y, x + 50, y) + .bezierCurveTo(x + 35, y, x + 25, y + 25, x + 25, y + 25); + + // Square + + var sqLength = 80; + + var squareShape = new THREE.Shape() + .moveTo(0, 0) + .lineTo(0, sqLength) + .lineTo(sqLength, sqLength) + .lineTo(sqLength, 0) + .lineTo(0, 0); + + // Rounded rectangle + + var roundedRectShape = new THREE.Shape(); + + (function roundedRect(ctx, x, y, width, height, radius) { + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y + height - radius); + ctx.quadraticCurveTo(x, y + height, x + radius, y + height); + ctx.lineTo(x + width - radius, y + height); + ctx.quadraticCurveTo(x + width, y + height, x + width, y + height - radius); + ctx.lineTo(x + width, y + radius); + ctx.quadraticCurveTo(x + width, y, x + width - radius, y); + ctx.lineTo(x + radius, y); + ctx.quadraticCurveTo(x, y, x, y + radius); + })(roundedRectShape, 0, 0, 50, 50, 20); + + // Track + + var trackShape = new THREE.Shape() + .moveTo(40, 40) + .lineTo(40, 160) + .absarc(60, 160, 20, Math.PI, 0, true) + .lineTo(80, 40) + .absarc(60, 40, 20, 2 * Math.PI, Math.PI, true); + + // Circle + + var circleRadius = 40; + var circleShape = new THREE.Shape() + .moveTo(0, circleRadius) + .quadraticCurveTo(circleRadius, circleRadius, circleRadius, 0) + .quadraticCurveTo(circleRadius, -circleRadius, 0, -circleRadius) + .quadraticCurveTo(-circleRadius, -circleRadius, -circleRadius, 0) + .quadraticCurveTo(-circleRadius, circleRadius, 0, circleRadius); + + // Fish + + var x = (y = 0); + + var fishShape = new THREE.Shape() + .moveTo(x, y) + .quadraticCurveTo(x + 50, y - 80, x + 90, y - 10) + .quadraticCurveTo(x + 100, y - 10, x + 115, y - 40) + .quadraticCurveTo(x + 115, y, x + 115, y + 40) + .quadraticCurveTo(x + 100, y + 10, x + 90, y + 10) + .quadraticCurveTo(x + 50, y + 80, x, y); + + // Arc circle + + var arcShape = new THREE.Shape().moveTo(50, 10).absarc(10, 10, 40, 0, Math.PI * 2, false); + + var holePath = new THREE.Path().moveTo(20, 10).absarc(10, 10, 10, 0, Math.PI * 2, true); + + arcShape.holes.push(holePath); + + // Smiley + + var smileyShape = new THREE.Shape().moveTo(80, 40).absarc(40, 40, 40, 0, Math.PI * 2, false); + + var smileyEye1Path = new THREE.Path().moveTo(35, 20).absellipse(25, 20, 10, 10, 0, Math.PI * 2, true); + + var smileyEye2Path = new THREE.Path().moveTo(65, 20).absarc(55, 20, 10, 0, Math.PI * 2, true); + + var smileyMouthPath = new THREE.Path() + .moveTo(20, 40) + .quadraticCurveTo(40, 60, 60, 40) + .bezierCurveTo(70, 45, 70, 50, 60, 60) + .quadraticCurveTo(40, 80, 20, 60) + .quadraticCurveTo(5, 50, 20, 40); + + smileyShape.holes.push(smileyEye1Path); + smileyShape.holes.push(smileyEye2Path); + smileyShape.holes.push(smileyMouthPath); + + // Spline shape + + var splinepts = []; + splinepts.push(new THREE.Vector2(70, 20)); + splinepts.push(new THREE.Vector2(80, 90)); + splinepts.push(new THREE.Vector2(-30, 70)); + splinepts.push(new THREE.Vector2(0, 0)); + + var splineShape = new THREE.Shape().moveTo(0, 0).splineThru(splinepts); + + var extrudeSettings = { + depth: 8, + bevelEnabled: true, + bevelSegments: 2, + steps: 2, + bevelSize: 1, + bevelThickness: 1 + }; + + // addShape( shape, color, x, y, z, rx, ry,rz, s ); + + addShape(californiaShape, extrudeSettings, 0xf08000, -300, -100, 0, 0, 0, 0, 1); + addShape(triangleShape, extrudeSettings, 0x8080f0, -180, 0, 0, 0, 0, 0, 1); + addShape(roundedRectShape, extrudeSettings, 0x008000, -150, 150, 0, 0, 0, 0, 1); + addShape(trackShape, extrudeSettings, 0x008080, 200, -100, 0, 0, 0, 0, 1); + addShape(squareShape, extrudeSettings, 0x0040f0, 150, 100, 0, 0, 0, 0, 1); + addShape(heartShape, extrudeSettings, 0xf00000, 60, 100, 0, 0, 0, Math.PI, 1); + addShape(circleShape, extrudeSettings, 0x00f000, 120, 250, 0, 0, 0, 0, 1); + addShape(fishShape, extrudeSettings, 0x404040, -60, 200, 0, 0, 0, 0, 1); + addShape(smileyShape, extrudeSettings, 0xf000f0, -200, 250, 0, 0, 0, Math.PI, 1); + addShape(arcShape, extrudeSettings, 0x804000, 150, 0, 0, 0, 0, 0, 1); + addShape(splineShape, extrudeSettings, 0x808080, -50, -100, 0, 0, 0, 0, 1); + + addLineShape(arcShape.holes[0], 0x804000, 150, 0, 0, 0, 0, 0, 1); + + for (var i = 0; i < smileyShape.holes.length; i += 1) { + addLineShape(smileyShape.holes[i], 0xf000f0, -200, 250, 0, 0, 0, Math.PI, 1); + } + + // + // preserveDrawingBuffer: true required for Percy + renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(window.innerWidth, window.innerHeight); + container.appendChild(renderer.domElement); + + document.addEventListener('mousedown', onDocumentMouseDown, false); + document.addEventListener('touchstart', onDocumentTouchStart, false); + document.addEventListener('touchmove', onDocumentTouchMove, false); + + // + + window.addEventListener('resize', onWindowResize, false); +} + +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + + renderer.setSize(window.innerWidth, window.innerHeight); +} + +// + +function onDocumentMouseDown(event) { + event.preventDefault(); + + document.addEventListener('mousemove', onDocumentMouseMove, false); + document.addEventListener('mouseup', onDocumentMouseUp, false); + document.addEventListener('mouseout', onDocumentMouseOut, false); + + mouseXOnMouseDown = event.clientX - windowHalfX; + targetRotationOnMouseDown = targetRotation; +} + +function onDocumentMouseMove(event) { + mouseX = event.clientX - windowHalfX; + + targetRotation = targetRotationOnMouseDown + (mouseX - mouseXOnMouseDown) * 0.02; +} + +function onDocumentMouseUp() { + document.removeEventListener('mousemove', onDocumentMouseMove, false); + document.removeEventListener('mouseup', onDocumentMouseUp, false); + document.removeEventListener('mouseout', onDocumentMouseOut, false); +} + +function onDocumentMouseOut() { + document.removeEventListener('mousemove', onDocumentMouseMove, false); + document.removeEventListener('mouseup', onDocumentMouseUp, false); + document.removeEventListener('mouseout', onDocumentMouseOut, false); +} + +function onDocumentTouchStart(event) { + if (event.touches.length == 1) { + event.preventDefault(); + + mouseXOnMouseDown = event.touches[0].pageX - windowHalfX; + targetRotationOnMouseDown = targetRotation; + } +} + +function onDocumentTouchMove(event) { + if (event.touches.length == 1) { + event.preventDefault(); + + mouseX = event.touches[0].pageX - windowHalfX; + targetRotation = targetRotationOnMouseDown + (mouseX - mouseXOnMouseDown) * 0.05; + } +} + +// + +function animate() { + requestAnimationFrame(animate); + + render(); +} + +function render() { + group.rotation.y += (targetRotation - group.rotation.y) * 0.05; + renderer.render(scene, camera); +} diff --git a/test/integration/testcases/serialize-canvas.html b/test/integration/testcases/serialize-canvas.html new file mode 100644 index 00000000..4d29811a --- /dev/null +++ b/test/integration/testcases/serialize-canvas.html @@ -0,0 +1,33 @@ + + + + Canvas serialize integration test case + + + +

Canvas

+

2d Graphs

+
+ +
+

+ + Credit Charts.js + +

+ +

WebGL

+
+

+ + Credit three.js + +

+ +

+ + + + + +