diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..a37dbd2 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "amex" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46f9c1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# node & npm +node_modules +*.log + +# test +test-results +__diff_output__ + +# OS +.DS_Store + +# IDE +.idea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c9565e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,145 @@ +# Contributing + +The following guidelines must be followed by all contributors to this repository. Please review them carefully and do not hesitate to ask for help. + +### Code of Conduct + +* Review and test your code before submitting a pull request. +* Be kind and professional. Avoid assumptions; oversights happen. +* Be clear and concise when documenting code; focus on value. +* Don't commit commented code to the main repo (stash locally, if needed). + +### Git Commit Guidelines + +We follow precise rules for git commit message formatting. These rules make it easier to review commit logs and improve contextual understanding of code changes. This also allows us to auto-generate the CHANGELOG from commit messages. + +Each commit message consists of a **header**, **body** and **footer**. + +#### Header + +The header is required and must not exceed 70 characters to ensure it is well-formatted in common git tools. It has a special format that includes a *type*, *scope* and *subject*: + +Syntax: + +```bash +(): +``` + +#### Type + +The *type* should always be lowercase as shown below. + +##### Allowed `` values: + +* **feat** (new feature for the user) +* **fix** (bug fix for the user, not a fix to build scripts) +* **docs** (changes to documentation) +* **style** (formatting, missing semi colons, etc; no functional code change) +* **refactor** (refactoring production code, eg. renaming a variable) +* **test** (adding missing tests, refactoring tests; no production code change) +* **chore** (updating build/env/packages, etc; no production code change) + +#### Scope + +The *scope* describes the affected code. The descriptor may be a route, component, feature, utility, etc. It should be one word or camelCased, if needed: + +```bash +feat(transactions): added column for quantity +feat(BalanceModule): initial setup +``` + +The commit headers above work well if the commit affects many parts of a larger feature. If changes are more specific, it may be too broad. To better clarify specific scopes, you should use a `feature/scope` syntax: + +```bash +fix(transaction/details): missing quantity field +``` + +The above syntax helps reduce verbosity in the _subject_. In comparison, consider the following example: + +```bash +fix(transaction): missing quantity field in txn details +``` + +Another scenario for scope is using a `route/scope` (or `context/scope`) syntax. This would be useful when a commit only affects a particular instance of code that is used in multiple places. + +*Example*: Transactions may be shown in multiple routes/contexts, but a bug affecting transaction actions may only exist under the "home" route, possibly related to other code. In such cases, you could use the following format: + +```bash +fix(home/transactions): txn actions not working +``` + +This header makes it clear that the fix is limited in scope to transactions within the home route/context. + +#### Subject + +Short summary of the commit. Avoid redundancy and simplify wording in ways that do not compromise understanding. + +Good: + +```bash +$ git commit -m "fix(nav/link): incorrect URL for Travel" +``` + +Bad: + +```bash +$ git commit -m "fix(nav): incorrect URL for Travel nav item :P" +``` + +> Note that the _Bad_ example results in a longer commit header. This is partly attributed to the scope not being more specific and personal expression tacked on the end. + +**Note regarding subjects for bug fixes:** + +Summarize _what is fixed_, rather than stating that it _is_ fixed. The _type_ ("fix") already specifies the state of the issue. + +For example, don't do: + +```bash +$ git commit -m "fix(nav): corrected Travel URL" +``` + +Instead, do: + +```bash +$ git commit -m "fix(nav): broken URL for Travel" +``` + + +#### Body and Footer (optional) + +The body and footer should wrap at 80 characters. + +The **body** describes the commit in more detail and should not be more than 1 paragraph (3-5 sentences). Details are important, but too much verbosity can inhibit understanding and productivity -- keep it clear and concise. + +The **footer** should only reference Pull Requests or Issues associated with the commit. + +For bug fixes that address open issues, the footer should be formatted like so: + +```bash +Closes #17, #26 +``` +and for Pull Requests, use the format: + +```bash +Related #37 +``` + +If a commit is associated with issues and pull requests, use the following format: + +```bash +Closes #17, #26 +Related #37 +``` +> Issues should always be referenced before pull requests, as shown above. + +#### Piecing It All Together + +Below is an example of a full commit message that includes a header, body and footer: + +```bash +refactor(nav/item): added prop (isActive) + +NavItem now supports an "isActive" property. This property is used to control the styling of active navigation links. + +Closes #21 +``` \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8770d2a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2016 American Express Travel Related Services Company, Inc. + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 395efe4..24442d0 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ # jest-image-snapshot + +Jest matcher that performs image comparisons using [Blink-diff](https://github.com/yahoo/blink-diff) and behaves just like [Jest snapshots](https://facebook.github.io/jest/docs/snapshot-testing.html) do! Very useful for browser visual comparison testing. + +## Installation: +1. Install: + ``` + npm i --save-dev jest-image-snapshot + ``` +2. Extend Jest's `expect`: + ```javascript + const { toMatchImageSnapshot } = require('jest-image-snapshot'); + + expect.extend({ toMatchImageSnapshot }); + ``` + +## Usage: + + ```javascript + it('should demonstrate this matcher`s usage', () => { + ... + expect(image).toMatchImageSnapshot(); + }); +``` + + Given an image (should be either a PNGImage instance or a Buffer instance with PNG data) the `toMatchImageSnapshot()` matcher will create a `__image_snapshots__` directory in the directory the test is in and will store the baseline snapshot image there on the first run. + + On subsequent test runs the matcher will compare the image being passed against the stored snapshot. + + To update the stored image snapshot run jest with `--updateSnapshot` or `-u` argument. All this works the same way as [Jest snapshots](https://facebook.github.io/jest/docs/snapshot-testing.html). + + Typically this matcher is used to for visual tests that run on a browser. For example: + ```javascript + it('should render correctly in browser', async () => { + // everything above the expect is pseudo-code, obvi + const browser = await launchChromeHeadless(); + await browser.goToUrl('https://google.com'); + const screenshot = await browser.takeScreenshot(); + + expect(screenshot).toMatchImageSnapshot(); + }); + }); + ``` + + ### Optional configuration: + + `toMatchImageSnapshot()` takes an optional options object where you can provide your own [blink-diff configuration parameters](http://yahoo.github.io/blink-diff/#object-usage) and/or a custom snapshot identifier string: + + ```javascript + it('should demonstrate this matcher`s usage with a custom blink-diff config', () => { + ... + const blinkDiffConfig = { perceptual: true }; + expect(image).toMatchImageSnapshot({ customDiffConfig: blinkDiffConfig, customSnapshotIdentifier: 'customSnapshotName' }); + }); + ``` + + Any blink-diff custom configuration can be provided so long as the values for `imageAPath`, `imageA`, `imageBPath`, `imageB`, or `imageOutputPath` are not changed as these are used internally. + + ## Contributing + We welcome Your interest in the American Express Open Source Community on Github. + Any Contributor to any Open Source Project managed by the American Express Open + Source Community must accept and sign an Agreement indicating agreement to the + terms below. Except for the rights granted in this Agreement to American Express + and to recipients of software distributed by American Express, You reserve all + right, title, and interest, if any, in and to Your Contributions. Please [fill + out the Agreement](http://goo.gl/forms/mIHWH1Dcuy). + + Please open pull requests against `develop` branch and see `CONTRIBUTING.md` for commit formatting details. + + ## License + Any contributions made under this project will be governed by the [Apache License + 2.0](https://github.com/americanexpress/jest-image-snapshot/blob/master/LICENSE.txt). + + ## Code of Conduct + This project adheres to the [American Express Community Guidelines](https://github.com/americanexpress/jest-image-snapshot/wiki/Code-of-Conduct). + By participating, you are expected to honor these guidelines. diff --git a/__tests__/.eslintrc.json b/__tests__/.eslintrc.json new file mode 100644 index 0000000..90b281c --- /dev/null +++ b/__tests__/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "amex/test" +} diff --git a/__tests__/src/__snapshots__/diff-snapshot.spec.js.snap b/__tests__/src/__snapshots__/diff-snapshot.spec.js.snap new file mode 100644 index 0000000..6b4a4b8 --- /dev/null +++ b/__tests__/src/__snapshots__/diff-snapshot.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`diff-snapshot diffImageToSnapshot should throw if an unsupported configuration is passed 1`] = `"Passing in options: imageAPath,imageA,imageBPath,imageB,imageOutputPath via Blink-Diff configuration is not supported as those option are internally used. Instead pass your image data as first argument to this function!"`; + +exports[`diff-snapshot should have a list of unsupported blink diff custom configurations 1`] = ` +Array [ + "imageAPath", + "imageA", + "imageBPath", + "imageB", + "imageOutputPath", +] +`; diff --git a/__tests__/src/__snapshots__/index.spec.js.snap b/__tests__/src/__snapshots__/index.spec.js.snap new file mode 100644 index 0000000..47b9cdc --- /dev/null +++ b/__tests__/src/__snapshots__/index.spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toMatchImageSnapshot passes diffImageToSnapshot everything it needs to create a snapshot and compare if needed 1`] = ` +Object { + "customDiffConfig": Object {}, + "imageData": "pretendthisisanimagebuffer", + "snapshotIdentifier": "test-spec-js-test-1", + "snapshotsDir": "path/to/__image_snapshots__", + "updateSnapshot": false, +} +`; + +exports[`toMatchImageSnapshot should fail when diff result is unknown 2`] = ` +"Expected image to match or be a close match to snapshot. +See diff for details: path/to/result.png" +`; + +exports[`toMatchImageSnapshot should fail when snapshot has a difference beyond allowed threshold 2`] = ` +"Expected image to match or be a close match to snapshot. +See diff for details: path/to/result.png" +`; + +exports[`toMatchImageSnapshot should throw an error if used with .not matcher 1`] = `"Jest: \`.not\` cannot be used with \`.toMatchImageSnapshot()\`."`; diff --git a/__tests__/src/diff-snapshot.spec.js b/__tests__/src/diff-snapshot.spec.js new file mode 100644 index 0000000..99c01bb --- /dev/null +++ b/__tests__/src/diff-snapshot.spec.js @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2017 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const fs = require('fs'); + +const mockRunSync = jest.fn(() => {}); + +jest.mock('blink-diff', () => jest.fn(() => ({ + runSync: mockRunSync, +}))); + +describe('diff-snapshot', () => { + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + }); + + it('should have a list of unsupported blink diff custom configurations', () => { + const { unsupportedDiffConfigKeys } = require('../../src/diff-snapshot'); + + expect(unsupportedDiffConfigKeys).toMatchSnapshot(); + }); + + describe('isDiffConfigValid', () => { + const { isDiffConfigValid } = require('../../src/diff-snapshot'); + + it('returns false if any configuration passed is included in the list of unsupported configurations', () => { + expect(isDiffConfigValid({ imageOutputPath: 'path/to/output.png' })).toBe(false); + }); + + it('returns true if no configuration passed is included in the list of unsupported configurations', () => { + expect(isDiffConfigValid({ supportedConfiguration: true })).toBe(true); + }); + }); + + describe('diffImageToSnapshot', () => { + const mockSnapshotsDir = '/path/to/snapshots'; + const mockSnapshotIdentifier = 'id1'; + const mockImageBuffer = 'pretendthisisimagebufferandnotjustastring'; + const mockMkdirSync = jest.fn(); + const mockMkdirpSync = jest.fn(); + const mockWriteFileSync = jest.fn(); + + function setupTest({ + snapshotDirExists, + snapshotExists, + outputDirExists, + defaultExists = true, + }) { + const mockFs = Object.assign({}, fs, { + existsSync: jest.fn(), + mkdirSync: mockMkdirSync, + writeFileSync: mockWriteFileSync, + }); + jest.mock('fs', () => mockFs); + jest.mock('mkdirp', () => ({ sync: mockMkdirpSync })); + const { diffImageToSnapshot } = require('../../src/diff-snapshot'); + + mockFs.existsSync.mockImplementation((path) => { + switch (path) { + case `${mockSnapshotsDir}/${mockSnapshotIdentifier}-snap.png`: + return snapshotExists; + case `${mockSnapshotsDir}/__diff_output__`: + return !!outputDirExists; + case mockSnapshotsDir: + return !!snapshotDirExists; + default: + return !!defaultExists; + } + }); + + return diffImageToSnapshot; + } + + it('should throw if an unsupported configuration is passed', () => { + const diffImageToSnapshot = setupTest({}); + expect(() => + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + customDiffConfig: { + imageOutputPath: 'path/to/output/dir', + }, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should run comparison if there is already a snapshot stored and updateSnapshot flag is not set', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(mockRunSync).toHaveBeenCalled(); + }); + + it('should merge custom configuration with default configuration if custom config is passed', () => { + const mockBlinkDiff = require('blink-diff'); + const diffImageToSnapshot = setupTest({ snapshotExists: true }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(mockBlinkDiff).toHaveBeenCalledWith({ + imageA: mockImageBuffer, + imageBPath: `${mockSnapshotsDir}/${mockSnapshotIdentifier}-snap.png`, + threshold: 0.01, + imageOutputPath: `${mockSnapshotsDir}/__diff_output__/${mockSnapshotIdentifier}-diff.png`, + thresholdType: 'percent', + }); + }); + + it('should create diff output directory if there is not one already', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true, outputDirExists: false }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(mockMkdirpSync).toHaveBeenCalledWith(`${mockSnapshotsDir}/__diff_output__`); + }); + + it('should not create diff output directory if there is one there already', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true, outputDirExists: true }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(mockMkdirSync).not.toHaveBeenCalledWith(`${mockSnapshotsDir}/__diff_output__`); + }); + + it('should create snapshots directory is there is not one already', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true, snapshotDirExists: false }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: true, + }); + + expect(mockMkdirpSync).toHaveBeenCalledWith(mockSnapshotsDir); + }); + + it('should not create snapshots directory if there already is one', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true, snapshotDirExists: true }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: true, + }); + + expect(mockMkdirSync).not.toHaveBeenCalledWith(mockSnapshotsDir); + }); + + it('should create snapshot in __image_snapshots__ directory if there is not a snapshot created yet', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: false, snapshotDirExists: false }); + diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(mockWriteFileSync).toHaveBeenCalledWith(`${mockSnapshotsDir}/${mockSnapshotIdentifier}-snap.png`, mockImageBuffer); + }); + + it('should return updated flag is snapshot was updated', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true }); + const diffResult = diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: true, + }); + + expect(diffResult).toHaveProperty('updated', true); + }); + + it('should return added flag is snapshot was added', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: false }); + const diffResult = diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(diffResult).toHaveProperty('added', true); + }); + + it('should return path to comparison output image if a comparison was performed', () => { + const diffImageToSnapshot = setupTest({ snapshotExists: true }); + const diffResult = diffImageToSnapshot({ + imageData: mockImageBuffer, + snapshotIdentifier: mockSnapshotIdentifier, + snapshotsDir: mockSnapshotsDir, + updateSnapshot: false, + }); + + expect(diffResult).toHaveProperty('diffOutputPath', `${mockSnapshotsDir}/__diff_output__/${mockSnapshotIdentifier}-diff.png`); + }); + }); +}); diff --git a/__tests__/src/index.spec.js b/__tests__/src/index.spec.js new file mode 100644 index 0000000..169b806 --- /dev/null +++ b/__tests__/src/index.spec.js @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2017 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +describe('toMatchImageSnapshot', () => { + function setupMock(diffImageToSnapshotResult) { + jest.doMock('../../src/diff-snapshot', () => ({ + diffImageToSnapshot: jest.fn(() => diffImageToSnapshotResult), + })); + } + + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + }); + + it('should throw an error if used with .not matcher', () => { + const mockDiffResult = { updated: false, code: 7 }; + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + expect.extend({ toMatchImageSnapshot }); + + expect(() => expect('pretendthisisanimagebuffer').not.toMatchImageSnapshot()) + .toThrowErrorMatchingSnapshot(); + }); + + it('should pass when snapshot is similar enough or same as baseline snapshot', () => { + const mockDiffResult = { updated: false, code: 7 }; + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + expect.extend({ toMatchImageSnapshot }); + + expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) + .not.toThrow(); + }); + + it('should fail when snapshot has a difference beyond allowed threshold', () => { + // code 1 is result too different: https://github.com/yahoo/blink-diff/blob/master/index.js#L267 + const mockDiffResult = { updated: false, code: 1, diffOutputPath: 'path/to/result.png' }; + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + expect.extend({ toMatchImageSnapshot }); + + + expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) + .toThrowErrorMatchingSnapshot(); + }); + + it('should fail when diff result is unknown', () => { + // code 0 is unknown result: https://github.com/yahoo/blink-diff/blob/master/index.js#L258 + const mockDiffResult = { updated: false, code: 0, diffOutputPath: 'path/to/result.png' }; + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + expect.extend({ toMatchImageSnapshot }); + + + expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) + .toThrowErrorMatchingSnapshot(); + }); + + it('should use custom blink-diff configuration if passed in', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test1', + isNot: false, + snapshotState: { + _counters: new Map(), + _updateSnapshot: 'none', + updated: undefined, + added: true, + }, + }; + const mockDiffResult = { updated: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + + const customDiffConfig = { threshold: 0.3 }; + matcherAtTest('pretendthisisanimagebuffer', { customDiffConfig }); + const { diffImageToSnapshot } = require('../../src/diff-snapshot'); + expect(diffImageToSnapshot.mock.calls[0][0].customDiffConfig).toBe(customDiffConfig); + }); + + it('passes diffImageToSnapshot everything it needs to create a snapshot and compare if needed', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test', + isNot: false, + snapshotState: { + _counters: new Map(), + _updateSnapshot: 'none', + updated: undefined, + added: true, + }, + }; + const mockDiffResult = { updated: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + + matcherAtTest('pretendthisisanimagebuffer'); + const { diffImageToSnapshot } = require('../../src/diff-snapshot'); + + expect(diffImageToSnapshot.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('passes uses user passed snapshot name if given', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test', + isNot: false, + snapshotState: { + _counters: new Map(), + _updateSnapshot: 'none', + updated: undefined, + added: true, + }, + }; + const mockDiffResult = { updated: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + + matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); + const { diffImageToSnapshot } = require('../../src/diff-snapshot'); + + expect(diffImageToSnapshot.mock.calls[0][0].snapshotIdentifier).toBe('custom-name'); + }); + + it('attempts to update snapshots if snapshotState has updateSnapshot flag set', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test1', + isNot: false, + snapshotState: { + _counters: new Map(), + _updateSnapshot: 'all', + updated: undefined, + added: true, + }, + }; + const mockDiffResult = { updated: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + + matcherAtTest('pretendthisisanimagebuffer'); + const { diffImageToSnapshot } = require('../../src/diff-snapshot'); + + expect(diffImageToSnapshot.mock.calls[0][0].updateSnapshot).toBe(true); + }); + + it('should work when a new snapshot is added', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test1', + isNot: false, + snapshotState: { + _counters: new Map(), + update: false, + updated: undefined, + added: true, + }, + }; + const mockDiffResult = { added: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + expect(() => matcherAtTest('pretendthisisanimagebuffer')).not.toThrow(); + }); + + it('should work when a snapshot is updated', () => { + const mockTestContext = { + testPath: 'path/to/test.spec.js', + currentTestName: 'test1', + isNot: false, + snapshotState: { + _counters: new Map(), + update: true, + updated: undefined, + added: undefined, + }, + }; + const mockDiffResult = { updated: true, code: 7 }; + + setupMock(mockDiffResult); + const { toMatchImageSnapshot } = require('../../src/index'); + const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); + expect(() => matcherAtTest('pretendthisisanimagebuffer')).not.toThrow(); + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ae5b16 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "jest-image-snapshot", + "version": "0.1.0", + "description": "Jest matcher for screenshot comparisons. Most commonly used for browser visual testing.", + "main": "src/index.js", + "scripts": { + "lint": "eslint ./ --ignore-path .gitignore --ext .js", + "test": "jest", + "posttest": "npm run lint" + }, + "keywords": [ + "test", + "amex", + "visual testing", + "css", + "jest", + "browser testing" + ], + "jest": { + "preset": "amex-jest-preset" + }, + "repository": { + "type": "git", + "url": "https://github.com/americanexpress/jest-image-snapshot.git" + }, + "author": "Andres Escobar (https://github.com/anescobar1991)", + "license": "Apache-2.0", + "devDependencies": { + "amex-jest-preset": "^3.1.0", + "eslint": "^3.19.0", + "eslint-config-amex": "^6.0.0", + "jest": "^20.0.0" + }, + "dependencies": { + "blink-diff": "^1.0.13", + "chalk": "^1.1.3", + "lodash": "^4.17.4", + "mkdirp": "^0.5.1" + }, + "peerDependencies": { + "jest": "^20.0.0" + } +} diff --git a/src/diff-snapshot.js b/src/diff-snapshot.js new file mode 100644 index 0000000..153f3b1 --- /dev/null +++ b/src/diff-snapshot.js @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2017 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +const fs = require('fs'); +const BlinkDiff = require('blink-diff'); +const intersection = require('lodash/intersection'); +const mkdirp = require('mkdirp'); +const path = require('path'); + +const unsupportedDiffConfigKeys = [ + 'imageAPath', + 'imageA', + 'imageBPath', + 'imageB', + 'imageOutputPath', +]; + +function isDiffConfigValid(customDiffConfig) { + return intersection(unsupportedDiffConfigKeys, Object.keys(customDiffConfig)).length === 0; +} + +function diffImageToSnapshot(options) { + const { + imageData, + snapshotIdentifier, + snapshotsDir, + updateSnapshot = false, + customDiffConfig = {}, + } = options; + + if (!isDiffConfigValid(customDiffConfig)) { + throw new Error( + `Passing in options: ${unsupportedDiffConfigKeys} via Blink-Diff configuration ` + + 'is not supported as those option are internally used. ' + + 'Instead pass your image data as first argument to this function!' + ); + } + + let result = {}; + const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`); + if (fs.existsSync(baselineSnapshotPath) && !updateSnapshot) { + const outputDir = path.join(snapshotsDir, '__diff_output__'); + const diffOutputPath = path.join(outputDir, `${snapshotIdentifier}-diff.png`); + const defaultBlinkDiffConfig = { + imageA: imageData, + imageBPath: baselineSnapshotPath, + thresholdType: 'percent', + threshold: 0.01, + imageOutputPath: diffOutputPath, + }; + + mkdirp.sync(outputDir); + const diffConfig = Object.assign({}, defaultBlinkDiffConfig, customDiffConfig); + const diff = new BlinkDiff(diffConfig); + const unformattedDiffResult = diff.runSync(); + + result = Object.assign( + {}, + unformattedDiffResult, + { diffOutputPath } + ); + } else { + mkdirp.sync(snapshotsDir); + fs.writeFileSync(baselineSnapshotPath, imageData); + + result = updateSnapshot ? { updated: true } : { added: true }; + } + return result; +} + +module.exports = { + unsupportedDiffConfigKeys, + diffImageToSnapshot, + isDiffConfigValid, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..00c5652 --- /dev/null +++ b/src/index.js @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2017 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +/* eslint-disable no-underscore-dangle */ +const kebabCase = require('lodash/kebabCase'); +const merge = require('lodash/merge'); +const path = require('path'); +const chalk = require('chalk'); +const { diffImageToSnapshot } = require('./diff-snapshot'); + +function updateSnapshotState(oldSnapshotState, newSnapshotState) { + return merge({}, oldSnapshotState, newSnapshotState); +} + +function toMatchImageSnapshot(received, { customSnapshotIdentifier = '', customDiffConfig = {} } = {}) { + const { testPath, currentTestName, isNot } = this; + let { snapshotState } = this; + if (isNot) { throw new Error('Jest: `.not` cannot be used with `.toMatchImageSnapshot()`.'); } + + updateSnapshotState(snapshotState, { _counters: snapshotState._counters.set(currentTestName, (snapshotState._counters.get(currentTestName) || 0) + 1) }); // eslint-disable-line max-len + const snapshotIdentifier = customSnapshotIdentifier || kebabCase(`${path.basename(testPath)}-${currentTestName}-${snapshotState._counters.get(currentTestName)}`); + + const result = diffImageToSnapshot({ + imageData: received, + snapshotIdentifier, + snapshotsDir: path.join(path.dirname(testPath), '__image_snapshots__'), + updateSnapshot: snapshotState._updateSnapshot === 'all', + customDiffConfig, + }); + let pass = true; + if (result.updated) { + // once transition away from jasmine is done this will be a lot more elegant and pure + // https://github.com/facebook/jest/pull/3668 + snapshotState = updateSnapshotState(snapshotState, { updated: snapshotState.updated += 1 }); + } else if (result.added) { + snapshotState = updateSnapshotState(snapshotState, { added: snapshotState.added += 1 }); + // see https://github.com/yahoo/blink-diff/blob/master/index.js#L251-L285 for result codes + } else if (result.code === 0 || result.code === 1) { + pass = false; + } + + const message = 'Expected image to match or be a close match to snapshot.\n' + + `${chalk.bold.red('See diff for details:')} ${chalk.red(result.diffOutputPath)}`; + + return { + message, + pass, + }; +} + +module.exports = { + toMatchImageSnapshot, + updateSnapshotState, +};