diff --git a/contributor_docs/unit_testing.md b/contributor_docs/unit_testing.md index f0f4f76cab..64ef0f73b9 100644 --- a/contributor_docs/unit_testing.md +++ b/contributor_docs/unit_testing.md @@ -118,3 +118,24 @@ test('keyIsPressed is a boolean', function() { Similarly we can use `assert.strictEqual(myp5.keyIsPressed, true)` to assert if the value is true. You can read more about chai's assert [here](https://www.chaijs.com/api/assert/) Now that you have written the tests, run them and see if the method behaves as expected. If not, create an issue for the same and if you want, you can even work on fixing it! + +## Visual tests + +Visual tests are a way to make sure sketches do not unexpectedly change when we change the implementation of p5.js features. Each visual test file lives in the `test/unit/visual/cases` folder. Inside each file there are multiple visual test cases. Each case creates a sample sketch, and then calls `screenshot()` to check how the sketch looks. + +```js +visualTest('2D objects maintain correct size', function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + p5.noStroke(); + p5.fill('red'); + p5.rectMode(p5.CENTER); + p5.rect(0, 0, p5.width/2, p5.height/2); + screenshot(); +}); +``` + +If you need to add a new test file, add it to that folder, then add the filename to the list in `test/visual/visualTestList.js`. Additionally, if you want that file to be run automatically as part of continuous integration on every pull request, add the filename to the `visual` list in `test/unit/spec.js`. + +When you add a new test, running `npm test` will generate new screenshots for any visual tests that do not yet have them. Those screenshots will then be used as a reference the next time tests run to make sure the sketch looks the same. If a test intentionally needs to look different, you can delete the folder matching the test name in the `test/unit/visual/screenshots` folder, and then re-run `npm test` to generate a new one. + +To manually inspect all visual tests, run `grunt yui:dev` to launch a local server, then go to http://127.0.0.1:9001/test/visual.html to see a list of all test cases. diff --git a/tasks/test/mocha-chrome.js b/tasks/test/mocha-chrome.js index 2fccc5eda7..dc247ab87c 100644 --- a/tasks/test/mocha-chrome.js +++ b/tasks/test/mocha-chrome.js @@ -4,6 +4,7 @@ const puppeteer = require('puppeteer'); const util = require('util'); const mapSeries = require('promise-map-series'); const fs = require('fs'); +const path = require('path'); const EventEmitter = require('events'); const mkdir = util.promisify(fs.mkdir); @@ -28,6 +29,28 @@ module.exports = function(grunt) { const page = await browser.newPage(); try { + // Set up visual tests + await page.evaluateOnNewDocument(function(shouldGenerateScreenshots) { + window.shouldGenerateScreenshots = shouldGenerateScreenshots; + }, !process.env.CI); + + await page.exposeFunction('writeImageFile', function(filename, base64Data) { + fs.mkdirSync('test/' + path.dirname(filename), { recursive: true }); + const prefix = /^data:image\/\w+;base64,/; + fs.writeFileSync( + 'test/' + filename, + base64Data.replace(prefix, ''), + 'base64' + ); + }); + await page.exposeFunction('writeTextFile', function(filename, data) { + fs.mkdirSync('test/' + path.dirname(filename), { recursive: true }); + fs.writeFileSync( + 'test/' + filename, + data + ); + }); + // Using eval to start the test in the browser // A 'mocha:end' event will be triggered with test runner end await page.evaluateOnNewDocument(` diff --git a/test/unit/spec.js b/test/unit/spec.js index 6d4c8f4142..64c8cc8f91 100644 --- a/test/unit/spec.js +++ b/test/unit/spec.js @@ -47,8 +47,16 @@ var spec = { 'p5.Shader', 'p5.Texture', 'light' + ], + 'visual/cases': [ + // Add the visual tests that you want run as part of CI here. Feel free + // to omit some for speed if they should only be run manually. + 'webgl' ] }; +document.write( + '' +); Object.keys(spec).map(function(folder) { spec[folder].map(function(file) { var string = [ diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js new file mode 100644 index 0000000000..c45ebda9a5 --- /dev/null +++ b/test/unit/visual/cases/webgl.js @@ -0,0 +1,37 @@ +visualSuite('WebGL', function() { + visualSuite('Camera', function() { + visualTest('2D objects maintain correct size', function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + p5.noStroke(); + p5.fill('red'); + p5.rectMode(p5.CENTER); + p5.rect(0, 0, p5.width/2, p5.height/2); + screenshot(); + }); + + visualTest('Custom camera before and after resize', function(p5, screenshot) { + p5.createCanvas(25, 50, p5.WEBGL); + const cam = p5.createCamera(); + p5.setCamera(cam); + cam.setPosition(-10, -10, 800); + p5.strokeWeight(4); + p5.box(20); + screenshot(); + + p5.resizeCanvas(50, 25); + p5.box(20); + screenshot(); + }); + }); + + visualSuite('Lights', function() { + visualTest('Fill color and default ambient material', function(p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + p5.noStroke(); + p5.lights(); + p5.fill('red'); + p5.sphere(20); + screenshot(); + }); + }); +}); diff --git a/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/000.png b/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/000.png new file mode 100644 index 0000000000..d2daba574f Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/metadata.json b/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/Camera/2D objects maintain correct size/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/000.png b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/000.png new file mode 100644 index 0000000000..b3cc6ed609 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/001.png b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/001.png new file mode 100644 index 0000000000..74fb513043 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/001.png differ diff --git a/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/metadata.json b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/metadata.json new file mode 100644 index 0000000000..ebf58a6cb0 --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/Camera/Custom camera before and after resize/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 2 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/000.png b/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/000.png new file mode 100644 index 0000000000..346374eb47 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/metadata.json b/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/Lights/Fill color and default ambient material/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js new file mode 100644 index 0000000000..24dbec2f53 --- /dev/null +++ b/test/unit/visual/visualTest.js @@ -0,0 +1,196 @@ +/** + * A helper class to contain an error and also the screenshot data that + * caused the error. + */ +class ScreenshotError extends Error { + constructor(message, actual, expected) { + super(message); + this.actual = actual; + this.expected = expected; + } +} + +function toBase64(img) { + return img.canvas.toDataURL(); +} + +function escapeName(name) { + // Encode slashes as `encodeURIComponent('/')` + return name.replace(/\//g, '%2F'); +} + +let namePrefix = ''; + +/** + * A helper to define a category of visual tests. + * + * @param name The name of the category of test. + * @param callback A callback that calls `visualTest` a number of times to define + * visual tests within this suite. + * @param [options] An options object with optional additional settings. Set its + * key `focus` to true to only run this test, or its `skip` key to skip it. + */ +window.visualSuite = function( + name, + callback, + { focus = false, skip = false } = {} +) { + const lastPrefix = namePrefix; + namePrefix += escapeName(name) + '/'; + + let suiteFn = suite; + if (focus) { + suiteFn = suiteFn.only; + } + if (skip) { + suiteFn = suiteFn.skip; + } + suiteFn(name, callback); + + namePrefix = lastPrefix; +}; + +window.checkMatch = function(actual, expected, p5) { + const maxSide = 50; + const scale = Math.min(maxSide/expected.width, maxSide/expected.height); + for (const img of [actual, expected]) { + img.resize( + Math.ceil(img.width * scale), + Math.ceil(img.height * scale) + ); + } + const diff = p5.createImage(actual.width, actual.height); + diff.drawingContext.drawImage(actual.canvas, 0, 0); + diff.drawingContext.globalCompositeOperation = 'difference'; + diff.drawingContext.drawImage(expected.canvas, 0, 0); + diff.filter(p5.ERODE, false); + diff.loadPixels(); + + let ok = true; + for (let i = 0; i < diff.pixels.length; i++) { + if (i % 4 === 3) continue; // Skip alpha checks + if (Math.abs(diff.pixels[i]) > 10) { + ok = false; + break; + } + } + return { ok, diff }; +}; + +/** + * A helper to define a visual test, where we will assert that a sketch matches + * screenshots saved ahead of time of what the test should look like. + * + * When defining a new test, run the tests once to generate initial screenshots. + * + * To regenerate screenshots for a test, delete its screenshots folder in + * the test/unit/visual/screenshots directory, and rerun the tests. + * + * @param testName The display name of a test. This also links the test to its + * expected screenshot, so make sure to rename the screenshot folder after + * renaming a test. + * @param callback A callback to set up the test case. It takes two parameters: + * first is `p5`, a reference to the p5 instance, and second is `screenshot`, a + * function to grab a screenshot of the canvas. It returns either nothing, or a + * Promise that resolves when all screenshots have been taken. + * @param [options] An options object with optional additional settings. Set its + * key `focus` to true to only run this test, or its `skip` key to skip it. + */ +window.visualTest = function( + testName, + callback, + { focus = false, skip = false } = {} +) { + const name = namePrefix + escapeName(testName); + let suiteFn = suite; + if (focus) { + suiteFn = suiteFn.only; + } + if (skip) { + suiteFn = suiteFn.skip; + } + + suiteFn(testName, function() { + let myp5; + + setup(function() { + return new Promise(res => { + myp5 = new p5(function(p) { + p.setup = function() { + res(); + }; + }); + }); + }); + + teardown(function() { + myp5.remove(); + }); + + test('matches expected screenshots', async function() { + let expectedScreenshots; + try { + metadata = await fetch( + `unit/visual/screenshots/${name}/metadata.json` + ).then(res => res.json()); + expectedScreenshots = metadata.numScreenshots; + } catch (e) { + expectedScreenshots = 0; + } + + if (!window.shouldGenerateScreenshots && !expectedScreenshots) { + // If running on CI, all expected screenshots should already + // be generated + throw new Error('No expected screenshots found'); + } + + const actual = []; + + // Generate screenshots + await callback(myp5, () => { + actual.push(myp5.get()); + }); + + if (expectedScreenshots && actual.length !== expectedScreenshots) { + throw new Error( + `Expected ${expectedScreenshots} screenshot(s) but generated ${actual.length}` + ); + } + if (!expectedScreenshots) { + writeTextFile( + `unit/visual/screenshots/${name}/metadata.json`, + JSON.stringify({ numScreenshots: actual.length }, null, 2) + ); + } + + const expectedFilenames = actual.map( + (_, i) => `unit/visual/screenshots/${name}/${i.toString().padStart(3, '0')}.png` + ); + const expected = expectedScreenshots + ? ( + await Promise.all( + expectedFilenames.map(path => new Promise((resolve, reject) => { + myp5.loadImage(path, resolve, reject); + })) + ) + ) + : []; + + for (let i = 0; i < actual.length; i++) { + if (expected[i]) { + if (!checkMatch(actual[i], expected[i], myp5).ok) { + throw new ScreenshotError( + `Screenshots do not match! Expected:\n${toBase64(expected[i])}\n\nReceived:\n${toBase64(actual[i])}\n\n` + + 'If this is unexpected, paste these URLs into your browser to inspect them, or run grunt yui:dev and go to http://127.0.0.1:9001/test/visual.html.\n\n' + + `If this change is expected, please delete the test/unit/visual/screenshots/${name} folder and run tests again to generate a new screenshot.`, + actual[i], + expected[i] + ); + } + } else { + writeImageFile(expectedFilenames[i], toBase64(actual[i])); + } + } + }); + }); +}; diff --git a/test/visual.html b/test/visual.html new file mode 100644 index 0000000000..0e25bdf88a --- /dev/null +++ b/test/visual.html @@ -0,0 +1,16 @@ + + + + + p5.js Visual Test Runner + + + +

p5.js Visual Test Runner

+

+ + + + + + diff --git a/test/visual/style.css b/test/visual/style.css new file mode 100644 index 0000000000..3b087f3db1 --- /dev/null +++ b/test/visual/style.css @@ -0,0 +1,45 @@ +body { + font-family: sans-serif; +} + +h4 { + font-weight: normal; + margin-bottom: 10px; + margin-top: 0; +} + +#metrics { + color: #777; + text-decoration: italic; +} + +.suite { + padding-left: 10px; + border-left: 2px solid rgba(0,0,0,0.2); + margin-bottom: 30px; +} +.skipped { + opacity: 0.5; +} +.suite.focused { + border-left-color: #2B2; +} +.suite.failed { + border-left-color: #F00; +} + +.failed { + color: #F00; +} + +.screenshot img { + border: 2px solid #000; + margin-right: 5px; +} +.screenshot.failed img { + border-color: #F00; +} + +.diff { + background: #000; +} diff --git a/test/visual/visualTestList.js b/test/visual/visualTestList.js new file mode 100644 index 0000000000..7a008a20ef --- /dev/null +++ b/test/visual/visualTestList.js @@ -0,0 +1,8 @@ +// List all visual test files here that should be manually run +const visualTestList = ['webgl']; + +for (const file of visualTestList) { + document.write( + `` + ); +} diff --git a/test/visual/visualTestRunner.js b/test/visual/visualTestRunner.js new file mode 100644 index 0000000000..ec7106b7ff --- /dev/null +++ b/test/visual/visualTestRunner.js @@ -0,0 +1,125 @@ +let parentEl = document.body; +let skipping = false; +let setups = []; +let teardowns = []; +const tests = []; + +window.devicePixelRatio = 1; + +// Force default antialiasing to match Chrome in puppeteer +const origSetAttributeDefaults = p5.RendererGL.prototype._setAttributeDefaults; +p5.RendererGL.prototype._setAttributeDefaults = function(pInst) { + origSetAttributeDefaults(pInst); + pInst._glAttributes = Object.assign({}, pInst._glAttributes); + pInst._glAttributes.antialias = true; +}; + +window.suite = function(name, callback) { + const prevSetups = setups; + const prevTeardowns = teardowns; + const prevParent = parentEl; + const suiteEl = document.createElement('div'); + suiteEl.classList.add('suite'); + const title = document.createElement('h4'); + title.innerText = decodeURIComponent(name); + suiteEl.appendChild(title); + parentEl.appendChild(suiteEl); + + parentEl = suiteEl; + setups = [...setups]; + teardowns = [...teardowns]; + callback(); + + parentEl = prevParent; + setups = prevSetups; + teardowns = prevTeardowns; + return suiteEl; +}; +window.suite.skip = function(name, callback) { + const prevSkipping = skipping; + skipping = true; + const el = window.suite(name, callback); + el.classList.add('skipped'); + skipping = prevSkipping; +}; +window.suite.only = function(name, callback) { + const el = window.suite(name, callback); + el.classList.add('focused'); +}; + +window.setup = function(cb) { + if (!cb) return; + setups.push(cb); +}; + +window.teardown = function(cb) { + if (!cb) return; + teardowns.push(cb); +}; + +window.test = function(_name, callback) { + const testEl = document.createElement('div'); + testEl.classList.add('test'); + parentEl.appendChild(testEl); + const currentParent = parentEl; + const testSetups = setups; + const testTeardowns = teardowns; + if (!skipping) { + tests.push(async function() { + const prevCheckMatch = window.checkMatch; + window.checkMatch = function(actual, expected, p5) { + let { ok, diff } = prevCheckMatch(actual, expected, p5); + + const screenshot = document.createElement('div'); + screenshot.classList.add('screenshot'); + const actualPreview = document.createElement('img'); + actualPreview.setAttribute('src', actual.canvas.toDataURL()); + actualPreview.setAttribute('title', 'Received'); + const expectedPreview = document.createElement('img'); + expectedPreview.setAttribute('src', expected.canvas.toDataURL()); + expectedPreview.setAttribute('title', 'Expected'); + const diffPreview = document.createElement('img'); + diffPreview.setAttribute('src', diff.canvas.toDataURL()); + diffPreview.setAttribute('title', 'Difference'); + diffPreview.classList.add('diff'); + screenshot.appendChild(actualPreview); + screenshot.appendChild(expectedPreview); + screenshot.appendChild(diffPreview); + if (!ok) { + screenshot.classList.add('failed'); + currentParent.classList.add('failed'); + } + testEl.appendChild(screenshot); + return { ok, diff }; + }; + try { + for (const setup of testSetups) { + await setup(); + } + await callback(); + } catch (e) { + if (!(e instanceof ScreenshotError)) { + const p = document.createElement('p'); + p.innerText = e.toString(); + testEl.appendChild(p); + } + testEl.classList.add('failed'); + } + for (const teardown of testTeardowns) { + await teardown(); + } + window.checkMatch = prevCheckMatch; + }); + } +}; + +window.addEventListener('load', async function() { + for (const test of tests) { + await test(); + } + + const numTotal = document.querySelectorAll('.test').length; + const numFailed = document.querySelectorAll('.test.failed').length; + document.getElementById('metrics').innerHTML = + `${numTotal - numFailed} passed out of ${numTotal}`; +});