├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── labeler.yml ├── pull_request_template.md └── workflows │ ├── health-check.yml │ ├── pr-labeler.yml │ ├── release.yml │ ├── stale.yml │ └── tests.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── __tests__ ├── .eslintrc.json ├── __image_snapshots__ │ ├── integration-6.png │ ├── integration-obsolete-6-snap.png │ ├── integration-spec-js-to-match-image-snapshot-failures-writes-a-result-image-for-failing-tests-ssim-3-snap.png │ ├── integration-spec-js-to-match-image-snapshot-happy-path-matches-an-identical-snapshot-1-snap.png │ ├── integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-base-64-encoded-strings-1-snap.png │ ├── integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-typed-array-1-snap.png │ └── integration-update.png ├── __snapshots__ │ ├── diff-snapshot.spec.js.snap │ └── index.spec.js.snap ├── diff-snapshot.spec.js ├── image-composer.spec.js ├── index.spec.js ├── integration.spec.js ├── outdated-snapshot-reporter.spec.js └── stubs │ ├── Desktop 1_082.png │ ├── Desktop 1_083.png │ ├── LargeTestImage-LargeTestImageFailure-ssim-diff.png │ ├── LargeTestImage.png │ ├── LargeTestImageFailure.png │ ├── TestImage.png │ ├── TestImage150x150.png │ ├── TestImageFailure.png │ ├── TestImageFailureOversize.png │ ├── TestImageUpdate1pxOff-onlyDiff-diff.png │ ├── TestImageUpdate1pxOff.png │ └── runtimeHooksPath.js ├── commitlint.config.js ├── examples ├── .eslintrc.json ├── README.md ├── __tests__ │ ├── __image_snapshots__ │ │ ├── local-image-spec-js-works-reading-an-image-from-the-local-file-system-1-snap.png │ │ └── puppeteer-example-spec-js-jest-image-snapshot-usage-with-an-image-received-from-puppeteer-works-1-snap.png │ ├── local-image.spec.js │ ├── puppeteer-example.spec.js │ └── stubs │ │ └── image.png ├── image-reporter.js ├── jest-setup.js └── package.json ├── images ├── create-snapshot.gif ├── fail-snapshot.gif ├── image-diff.png └── jest-image-snapshot.png ├── package-lock.json ├── package.json └── src ├── diff-process.js ├── diff-snapshot.js ├── image-composer.js ├── index.js └── outdated-snapshot-reporter.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | one-app-team-review-requested: 2 | - '**/*' 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Types of Changes 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | - [ ] Documentation (adding or updating documentation) 21 | - [ ] Dependency update 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My change requires a change to the documentation and I have updated the documentation accordingly. 27 | - [ ] My changes are in sync with the code style of this project. 28 | - [ ] There aren't any other open Pull Requests for the same issue/update. 29 | - [ ] These changes should be applied to a maintenance branch. 30 | - [ ] I have added the Apache 2.0 license header to any new files created. 31 | 32 | ## What is the Impact to Developers Using Jest-Image-Snapshot? 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/health-check.yml: -------------------------------------------------------------------------------- 1 | name: Health Check 2 | 3 | on: 4 | schedule: 5 | # At minute 0 past hour 0800 and 2000. 6 | - cron: '0 8,20 * * *' 7 | 8 | jobs: 9 | tests: 10 | strategy: 11 | matrix: 12 | node: [ '18.x', '20.x', '22.x' ] 13 | os: [ ubuntu-latest, windows-latest, macos-latest ] 14 | runs-on: ${{ matrix.os }} 15 | name: Node ${{ matrix.node }} - ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: | 19 | git remote set-branches --add origin main 20 | git fetch 21 | - name: Setup Node 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node }} 25 | - name: Install Dependencies 26 | run: npm ci 27 | env: 28 | NODE_ENV: development 29 | - name: Run Test Script 30 | run: npm run test 31 | env: 32 | NODE_ENV: production 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | pull_request_target: 4 | types: [opened, reopened] 5 | 6 | jobs: 7 | triage: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/labeler@v3 11 | with: 12 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | prepare: 10 | runs-on: ubuntu-latest 11 | if: "! contains(github.event.head_commit.message, '[skip ci]')" 12 | steps: 13 | - run: echo "${{ github.event.head_commit.message }}" 14 | release: 15 | needs: prepare 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | persist-credentials: false 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: 18 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Release 30 | env: 31 | GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} 32 | GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} 33 | GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} 34 | GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} 35 | GITHUB_TOKEN: ${{ secrets.PA_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: npx semantic-release 38 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v3 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity.' 17 | stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity.' 18 | stale-issue-label: 'stale-issue' 19 | exempt-issue-labels: 'enhancement,documentation,good-first-issue,question,bug' 20 | stale-pr-label: 'stale-pr' 21 | exempt-pr-labels: 'work-in-progress' 22 | days-before-stale: 30 23 | days-before-close: -1 24 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | tests: 10 | strategy: 11 | matrix: 12 | node: [ '18.x', '20.x', '22.x' ] 13 | os: [ ubuntu-latest, windows-latest, macos-latest ] 14 | runs-on: ${{ matrix.os }} 15 | name: Node ${{ matrix.node }} - ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: | 19 | git remote set-branches --add origin main 20 | git fetch 21 | - name: Setup Node 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node }} 25 | - name: Install Dependencies 26 | run: npm ci 27 | env: 28 | NODE_ENV: development 29 | - name: Unit Tests 30 | run: npm run test 31 | env: 32 | NODE_ENV: production 33 | - name: Lockfile Lint Test 34 | run: npm run test:lockfile 35 | env: 36 | NODE_ENV: production 37 | - name: Git History Test 38 | run: npm run test:git-history 39 | env: 40 | NODE_ENV: production 41 | - name: Lint 42 | run: npm run lint 43 | env: 44 | NODE_ENV: production 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node & npm 2 | node_modules 3 | *.log 4 | 5 | # test 6 | test-results 7 | __diff_output__ 8 | .jest-cache 9 | 10 | # OS 11 | .DS_Store 12 | 13 | # IDE 14 | .idea 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # node & npm 2 | *.log 3 | 4 | # github 5 | .github 6 | 7 | # test 8 | test-results 9 | __diff_output__ 10 | .jest-cache 11 | __tests__ 12 | 13 | # examples 14 | examples 15 | 16 | # doc assets 17 | images 18 | 19 | # IDE 20 | .idea 21 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [6.5.1](https://github.com/americanexpress/jest-image-snapshot/compare/v6.5.0...v6.5.1) (2025-05-20) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * work with base64 strings ([#368](https://github.com/americanexpress/jest-image-snapshot/issues/368)) ([9a3ca4f](https://github.com/americanexpress/jest-image-snapshot/commit/9a3ca4f173669250a49b9a5709f8ba933b55fc78)) 7 | 8 | # [6.5.0](https://github.com/americanexpress/jest-image-snapshot/compare/v6.4.0...v6.5.0) (2025-05-12) 9 | 10 | 11 | ### Features 12 | 13 | * work with TypedArray / Array / ArrayBuffer ([b419a4d](https://github.com/americanexpress/jest-image-snapshot/commit/b419a4dfbd6a34470f2833073f4f714d98a871c9)) 14 | 15 | # [6.4.0](https://github.com/americanexpress/jest-image-snapshot/compare/v6.3.0...v6.4.0) (2023-12-11) 16 | 17 | 18 | ### Features 19 | 20 | * add configurable maxBuffer option to runDiffImageToSnapshot ([#344](https://github.com/americanexpress/jest-image-snapshot/issues/344)) ([befad8b](https://github.com/americanexpress/jest-image-snapshot/commit/befad8ba6080be6b0a94d098334ea05258afab2e)) 21 | 22 | # [6.3.0](https://github.com/americanexpress/jest-image-snapshot/compare/v6.2.0...v6.3.0) (2023-11-28) 23 | 24 | 25 | ### Features 26 | 27 | * Add `runtimeHooksPath` options ([#337](https://github.com/americanexpress/jest-image-snapshot/issues/337)) ([57741a2](https://github.com/americanexpress/jest-image-snapshot/commit/57741a242cd2192c453a87c34fa89c7c35a0763c)) 28 | 29 | # [6.2.0](https://github.com/americanexpress/jest-image-snapshot/compare/v6.1.1...v6.2.0) (2023-07-25) 30 | 31 | 32 | ### Features 33 | 34 | * allow configuration of postfix for received screenshots filename ([#328](https://github.com/americanexpress/jest-image-snapshot/issues/328)) ([bade294](https://github.com/americanexpress/jest-image-snapshot/commit/bade294ec2843c62b1dbcbf894faffd3a5708b98)) 35 | 36 | ## [6.1.1](https://github.com/americanexpress/jest-image-snapshot/compare/v6.1.0...v6.1.1) (2023-07-25) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * only updatePassedSnapshot if updateSnapshot is also true ([#327](https://github.com/americanexpress/jest-image-snapshot/issues/327)) ([b9d9c3f](https://github.com/americanexpress/jest-image-snapshot/commit/b9d9c3f16ab0e10a3e1320d03efb52e81675d2aa)), closes [#320](https://github.com/americanexpress/jest-image-snapshot/issues/320) [#322](https://github.com/americanexpress/jest-image-snapshot/issues/322) [#324](https://github.com/americanexpress/jest-image-snapshot/issues/324) 42 | 43 | # [6.1.0](https://github.com/americanexpress/jest-image-snapshot/compare/v6.0.0...v6.1.0) (2022-12-02) 44 | 45 | 46 | ### Features 47 | 48 | * add onlyDiff in options ([#317](https://github.com/americanexpress/jest-image-snapshot/issues/317)) ([4bad752](https://github.com/americanexpress/jest-image-snapshot/commit/4bad752571bb567861ddfa2cc9073f33c4239352)) 49 | 50 | # [6.0.0](https://github.com/americanexpress/jest-image-snapshot/compare/v5.2.0...v6.0.0) (2022-11-03) 51 | 52 | 53 | * chore(jest)!: add support for jest v29 (#309) ([79e53fc](https://github.com/americanexpress/jest-image-snapshot/commit/79e53fc010793f574cd9da783ced895af6987712)), closes [#309](https://github.com/americanexpress/jest-image-snapshot/issues/309) 54 | 55 | 56 | ### BREAKING CHANGES 57 | 58 | * Drop support for Node v12 and Node v17, 59 | as Jest v29 does not support these versions. 60 | 61 | * ci(release): use Node v16 for release action 62 | 63 | Node v16 is the current active LTS release of Node.JS 64 | 65 | Co-authored-by: Jamie King 66 | 67 | Co-authored-by: Jamie King 68 | 69 | # [5.2.0](https://github.com/americanexpress/jest-image-snapshot/compare/v5.1.1...v5.2.0) (2022-08-31) 70 | 71 | 72 | ### Features 73 | 74 | * remove snap suffix if use custom identifier ([#305](https://github.com/americanexpress/jest-image-snapshot/issues/305)) ([775ac0a](https://github.com/americanexpress/jest-image-snapshot/commit/775ac0a7dff33da9719b1dc36b9e382dc10a82a1)) 75 | 76 | ## [5.1.1](https://github.com/americanexpress/jest-image-snapshot/compare/v5.1.0...v5.1.1) (2022-08-25) 77 | 78 | 79 | ### Bug Fixes 80 | 81 | * **diff-snapshot:** make recievedDir optional ([#306](https://github.com/americanexpress/jest-image-snapshot/issues/306)) ([cd4fa73](https://github.com/americanexpress/jest-image-snapshot/commit/cd4fa734dd72f8e590e8b672c3081468a5842a20)), closes [#300](https://github.com/americanexpress/jest-image-snapshot/issues/300) 82 | 83 | # [5.1.0](https://github.com/americanexpress/jest-image-snapshot/compare/v5.0.0...v5.1.0) (2022-05-30) 84 | 85 | 86 | ### Features 87 | 88 | * allow storing received screenshot on failure ([#298](https://github.com/americanexpress/jest-image-snapshot/issues/298)) ([cfb81c9](https://github.com/americanexpress/jest-image-snapshot/commit/cfb81c99e1465420f007e180a59559c5d62d1c67)) 89 | 90 | # [5.0.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.5.1...v5.0.0) (2022-05-30) 91 | 92 | 93 | ### chore 94 | 95 | * **jest:** upgrade v28 ([a902a5b](https://github.com/americanexpress/jest-image-snapshot/commit/a902a5baa87d0925b3ff241d7f592f6e1fc0c8fd)), closes [#296](https://github.com/americanexpress/jest-image-snapshot/issues/296) 96 | 97 | 98 | ### BREAKING CHANGES 99 | 100 | * **jest:** drop support for Node 10 due 101 | to jest use of globalThis in Node 12 102 | 103 | ## [4.5.1](https://github.com/americanexpress/jest-image-snapshot/compare/v4.5.0...v4.5.1) (2021-06-25) 104 | 105 | 106 | ### Bug Fixes 107 | 108 | * **deps:** bump glob-parent from 5.1.1 to 5.1.2 ([#276](https://github.com/americanexpress/jest-image-snapshot/issues/276)) ([0c5879e](https://github.com/americanexpress/jest-image-snapshot/commit/0c5879ea2552682208e822d5d696c94121ed7125)) 109 | 110 | # [4.5.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.4.1...v4.5.0) (2021-04-29) 111 | 112 | 113 | ### Features 114 | 115 | * allow folders on snapshot identifier ([#267](https://github.com/americanexpress/jest-image-snapshot/issues/267)) ([ad49a97](https://github.com/americanexpress/jest-image-snapshot/commit/ad49a975a425826245a3f72e4df262db09ce25ea)) 116 | 117 | ## [4.4.1](https://github.com/americanexpress/jest-image-snapshot/compare/v4.4.0...v4.4.1) (2021-04-01) 118 | 119 | 120 | ### Bug Fixes 121 | 122 | * incorrect stats reported to jest ([#257](https://github.com/americanexpress/jest-image-snapshot/issues/257)) ([e8f949a](https://github.com/americanexpress/jest-image-snapshot/commit/e8f949a845facf4e0fc47f6f63ab59f791d4148a)) 123 | 124 | # [4.4.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.3.0...v4.4.0) (2021-02-26) 125 | 126 | 127 | ### Features 128 | 129 | * better error message ([#254](https://github.com/americanexpress/jest-image-snapshot/issues/254)) ([af44dd4](https://github.com/americanexpress/jest-image-snapshot/commit/af44dd49bf82caefb289b7780c97a1ba6bcc9e8d)) 130 | 131 | # [4.3.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.2.0...v4.3.0) (2020-12-16) 132 | 133 | 134 | ### Features 135 | 136 | * inline images support ([#244](https://github.com/americanexpress/jest-image-snapshot/issues/244)) ([b82b068](https://github.com/americanexpress/jest-image-snapshot/commit/b82b068c6e001a2e098ac2fbde3abc55ffeb493b)) 137 | 138 | # [4.2.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.1.0...v4.2.0) (2020-08-29) 139 | 140 | 141 | ### Features 142 | 143 | * add obsolete snapshot reporting ([#222](https://github.com/americanexpress/jest-image-snapshot/issues/222)) ([47da7c2](https://github.com/americanexpress/jest-image-snapshot/commit/47da7c23495037e869ee68154218e5d73e1e8cd5)) 144 | 145 | # [4.1.0](https://github.com/americanexpress/jest-image-snapshot/compare/v4.0.2...v4.1.0) (2020-07-23) 146 | 147 | 148 | ### Features 149 | 150 | * **ssim:** add integration ([#220](https://github.com/americanexpress/jest-image-snapshot/issues/220)) ([e2b304a](https://github.com/americanexpress/jest-image-snapshot/commit/e2b304a6c6aaf7e1d12c2e088105181ee108b960)) 151 | 152 | ## [4.0.2](https://github.com/americanexpress/jest-image-snapshot/compare/v4.0.1...v4.0.2) (2020-05-27) 153 | 154 | 155 | ### Bug Fixes 156 | 157 | * **options:** auto-detect colors if noColors option is not specified ([d90298c](https://github.com/americanexpress/jest-image-snapshot/commit/d90298c3f102734107a7574ddf0516c19a349c66)) 158 | 159 | ## [4.0.1](https://github.com/americanexpress/jest-image-snapshot/compare/v4.0.0...v4.0.1) (2020-05-27) 160 | 161 | 162 | ### Bug Fixes 163 | 164 | * **options:** add allowSizeMismatch arg ([6529ff4](https://github.com/americanexpress/jest-image-snapshot/commit/6529ff4b2bd9a20abe33d4b68d9d793198931f18)) 165 | 166 | # [4.0.0](https://github.com/americanexpress/jest-image-snapshot/compare/v3.1.0...v4.0.0) (2020-05-14) 167 | 168 | 169 | ### chore 170 | 171 | * upgrade to jest 26 + drop node 8 support ([#205](https://github.com/americanexpress/jest-image-snapshot/issues/205)) ([4834533](https://github.com/americanexpress/jest-image-snapshot/commit/4834533369dae1533c93ad883e3f66617d7d9c3f)) 172 | 173 | 174 | ### BREAKING CHANGES 175 | 176 | * drop node 8 support 177 | 178 | # [3.1.0](https://github.com/americanexpress/jest-image-snapshot/compare/v3.0.1...v3.1.0) (2020-04-17) 179 | 180 | 181 | ### Features 182 | 183 | * **options:** add option to pass on size missmatch ([#174](https://github.com/americanexpress/jest-image-snapshot/issues/174)) ([cee46b1](https://github.com/americanexpress/jest-image-snapshot/commit/cee46b1fc94f962c34900a8b655d22665cea2854)), closes [#83](https://github.com/americanexpress/jest-image-snapshot/issues/83) [#85](https://github.com/americanexpress/jest-image-snapshot/issues/85) 184 | 185 | ## [3.0.1](https://github.com/americanexpress/jest-image-snapshot/compare/v3.0.0...v3.0.1) (2020-03-25) 186 | 187 | 188 | ### Performance Improvements 189 | 190 | * **diff-snapshot:** remove logic to bypass diff for identical images ([1be1b00](https://github.com/americanexpress/jest-image-snapshot/commit/1be1b006220b4144f98ad583c8cd6ff629aec7b3)) 191 | 192 | # [3.0.0](https://github.com/americanexpress/jest-image-snapshot/compare/v2.12.0...v3.0.0) (2020-03-24) 193 | 194 | 195 | ### Bug Fixes 196 | 197 | * **diff:** small default maxBuffer ([df713f6](https://github.com/americanexpress/jest-image-snapshot/commit/df713f6afb7ec7130ec07e94d6a137a3ea62c5de)) 198 | * **diff-snapshot:** dumpDiffToConsole base64 string output ([#183](https://github.com/americanexpress/jest-image-snapshot/issues/183)) ([f73079f](https://github.com/americanexpress/jest-image-snapshot/commit/f73079f42f86696831ebe85d718e27d6f1d048c0)) 199 | 200 | 201 | ### chore 202 | 203 | * **packages:** updating jest to 25.1 for perf improvements ([#170](https://github.com/americanexpress/jest-image-snapshot/issues/170)) ([eb3dfa6](https://github.com/americanexpress/jest-image-snapshot/commit/eb3dfa605c0344ac4dc42cb7f9f76a5e4a732592)) 204 | * **packages:** upgrade from pixelmatch 4.x to 5.x, and pngjs to 3.4 ([#186](https://github.com/americanexpress/jest-image-snapshot/issues/186)) ([1edc9a3](https://github.com/americanexpress/jest-image-snapshot/commit/1edc9a31db2130b1eafb45738ebc81fa544d380f)) 205 | * **travis:** remove node 6 from travis config ([ce2b757](https://github.com/americanexpress/jest-image-snapshot/commit/ce2b757a6f337ac156e901a0f1e2851f94d0e7b2)) 206 | 207 | 208 | ### Features 209 | 210 | * **diff:** increase the maxBuffer to 10MB for the diff process ([#167](https://github.com/americanexpress/jest-image-snapshot/issues/167)) ([0927826](https://github.com/americanexpress/jest-image-snapshot/commit/0927826776e5fee04ea98cba5cd792aa5066e1fd)) 211 | 212 | 213 | ### BREAKING CHANGES 214 | 215 | * **packages:** pixelmatch is being major version bumped and so image diffs may be difference 216 | * **packages:** Node min version is now 8 217 | * **travis:** drop support for node 6 218 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/articles/about-code-owners 2 | 3 | * @americanexpress/one -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ### American Express Open Source Community Guidelines 2 | 3 | #### Last Modified: January 29, 2016 4 | 5 | Welcome to the American Express Open Source Community on GitHub! These American Express Community Guidelines outline our expectations for Github participating members within the American Express community, as well as steps for reporting unacceptable behavior. We are committed to providing a welcoming and inspiring community for all and expect our community Guidelines to be honored. 6 | 7 | **IMPORTANT REMINDER:** 8 | 9 | When you visit American Express on any third party sites such as GitHub your activity there is subject to that site’s then current terms of use., along with their privacy and data security practices and policies. The Github platform is not affiliated with us and may have practices and policies that are different than are our own. 10 | Please note, American Express is not responsible for, and does not control, the GitHub site’s terms of use, privacy and data security practices and policies. You should, therefore, always exercise caution when posting, sharing or otherwise taking any action on that site and, of course, on the Internet in general. 11 | Our open source community strives to: 12 | - **Be friendly and patient**. 13 | - **Be welcoming**: We strive to be a community that welcomes and supports people of all 14 | backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, color, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. 15 | - **Be considerate**: Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. 16 | - **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. 17 | - **Be careful in the words that we choose**: We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. 18 | - **Try to understand why we disagree**: Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re all different people. The strength of our community comes from its diversity, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. 19 | 20 | ### Definitions 21 | Harassment includes, but is not limited to: 22 | - Offensive comments related to gender, gender identity and expression, sexual orientation, disability, mental illness, neuro(a)typicality, physical appearance, body size, race, age, regional discrimination, political or religious affiliation 23 | - Unwelcome comments regarding a person’s lifestyle choices and practices, including those related to food, health, parenting, drugs, and employment 24 | - Deliberate misgendering. This includes deadnaming or persistently using a pronoun that does not correctly reflect a person's gender identity. You must address people by the name they give you when not addressing them by their username or handle 25 | - Physical contact and simulated physical contact (eg, textual descriptions like “hug” or “backrub”) without consent or after a request to stop 26 | - Threats of violence, both physical and psychological 27 | - Incitement of violence towards any individual, including encouraging a person to commit suicide 28 | or to engage in self-harm 29 | - Deliberate intimidation 30 | - Stalking or following 31 | - Harassing photography or recording, including logging online activity for harassment purposes 32 | - Sustained disruption of discussion 33 | - Unwelcome sexual attention, including gratuitous or off-topic sexual images or behaviour 34 | - Pattern of inappropriate social contact, such as requesting/assuming inappropriate levels of 35 | intimacy with others 36 | - Continued one-on-one communication after requests to cease 37 | - Deliberate “outing” of any aspect of a person’s identity without their consent except as necessary 38 | to protect others from intentional abuse 39 | - Publication of non-harassing private communication 40 | 41 | Our open source community prioritizes marginalized people’s safety over privileged people’s comfort. We will not act on complaints regarding: 42 | - ‘Reverse’ -isms, including ‘reverse racism,’ ‘reverse sexism,’ and ‘cisphobia’ 43 | - Reasonable communication of boundaries, such as “leave me alone,” “go away,” or “I’m not 44 | discussing this with you” 45 | - Refusal to explain or debate social justice concepts 46 | - Communicating in a ‘tone’ you don’t find congenial 47 | - Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions 48 | 49 | ### Diversity Statement 50 | We encourage everyone to participate and are committed to building a community for all. Although we will fail at times, we seek to treat everyone both as fairly and equally as possible. Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do our best to right the wrong. 51 | 52 | Although this list cannot be exhaustive, we explicitly honor diversity in age, gender, gender identity or expression, culture, ethnicity, language, national origin, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate discrimination based on any of the protected characteristics above, including participants with disabilities. 53 | 54 | ### Reporting Issues 55 | If you experience or witness unacceptable behavior—or have any other concerns—please report it by contacting us at opensource@aexp.com. All reports will be handled with discretion. In your report please include: 56 | - Your contact information. 57 | - Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional 58 | witnesses, please include them as well. Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public IRC logger), please include a link. 59 | - Any additional information that may be helpful. 60 | 61 | After filing a report, a representative of our community will contact you personally, review the incident, follow up with any additional questions, and make a decision as to how to respond. If the person who is harassing you is part of the response team, they will recuse themselves from handling your incident. If the complaint originates from a member of the response team, it will be handled by a different member of the response team. We will respect confidentiality requests for the purpose of protecting victims of abuse. 62 | 63 | ### Removal of Posts 64 | We will not review every comment or post, but we reserve the right to remove any that violates these Guidelines or that, in our sole discretion, we otherwise consider objectionable and we may ban offenders from our community. 65 | 66 | ### Suspension/Termination/Reporting to Authority 67 | In certain instances, we may suspend, terminate or ban certain repeat offenders and/or those committing significant violations of these Guidelines. When appropriate, we may also, on our own or as required by the GitHub terms of use, be required to refer and/or work with GitHub and/or the appropriate authorities to review and/or pursue certain violations. 68 | 69 | ### Attribution & Acknowledgements 70 | These Guidelines have been adapted from the [Code of Conduct of the TODO group](http://todogroup.org/opencodeofconduct/). They are subject to revision by American Express and may be revised from time to time. 71 | 72 | Thank you for your participation! 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to jest-image-snapshot 2 | 3 | ✨ Thank you for taking the time to contribute to this project ✨ 4 | 5 | ## 📖 Table of Contents 6 | 7 | * [Code of Conduct](#code-of-conduct) 8 | * [Developing](#developing) 9 | * [Submitting a new feature](#submitting-a-new-feature) 10 | * [Reporting bugs](#reporting-bugs) 11 | * [Contributing](#getting-in-contact) 12 | * [Coding conventions](#coding-conventions) 13 | 14 | ## Code of Conduct 15 | 16 | This project adheres to the American Express [Code of Conduct](./CODE_OF_CONDUCT.md). By contributing, you are expected to honor these guidelines. 17 | 18 | ## Developing 19 | 20 | ### Installation 21 | 22 | 1. Fork the repository `jest-image-snapshot` to your GitHub account. 23 | 2. Afterwards run the following commands in your terminal 24 | 25 | ```bash 26 | $ git clone https://github.com//jest-image-snapshot 27 | $ cd jest-image-snapshot 28 | ``` 29 | 30 | > replace `your-github-username` with your github username 31 | 32 | 3. Install and use the correct version of node (specified in `.nvmrc`) 33 | 34 | ```bash 35 | $ nvm use 36 | ``` 37 | 38 | 4. Install the dependencies by running 39 | 40 | ```bash 41 | $ npm install 42 | ``` 43 | 44 | 5. You can now run any of these scripts from the root folder. 45 | 46 | #### Running tests 47 | 48 | - **`npm run lint`** 49 | 50 | Verifies that your code matches the American Express code style defined in [`eslint-config-amex`](https://github.com/americanexpress/eslint-config-amex). 51 | 52 | - **`npm test`** 53 | 54 | Runs unit tests **and** verifies the format of all commit messages on the current branch. 55 | 56 | - **`npm run posttest`** 57 | 58 | Runs linting on the current branch, checks that the commits follow [conventional commits](https://www.conventionalcommits.org/) and verifies that the `package-lock.json` file includes public NPM registry URLs. 59 | 60 | ## Submitting a new feature 61 | 62 | When submitting a new feature request or enhancement of an existing feature please review the following:- 63 | 64 | ### Is your feature request related to a problem 65 | 66 | Please provide a clear and concise description of what you want and what your use case is. 67 | 68 | ### Provide an example 69 | 70 | Please include a snippets of the code of the new feature. 71 | 72 | ### Describe the suggested enhancement 73 | 74 | A clear and concise description of the enhancement to be added include a step-by-step guide if applicable. 75 | Add any other context or screenshots or animated GIFs about the feature request 76 | 77 | ### Describe alternatives you've considered 78 | 79 | A clear and concise description of any alternative solutions or features you've considered. 80 | 81 | ## Reporting bugs 82 | 83 | All issues are submitted within GitHub issues. Please check this before submitting a new issue. 84 | 85 | ### Describe the bug 86 | 87 | A clear and concise description of what the bug is. 88 | 89 | ### Provide step-by-step guide on how to reproduce the bug 90 | 91 | Steps to reproduce the behavior, please provide code snippets or a link to repository 92 | 93 | ### Expected behavior 94 | 95 | Please provide a description of the expected behavior 96 | 97 | ### Screenshots 98 | 99 | If applicable, add screenshots or animated GIFs to help explain your problem. 100 | 101 | ### System information 102 | 103 | Provide the system information which is not limited to the below: 104 | 105 | - Browser (if applies) [e.g. chrome, safari] 106 | - Version of jest-image-snapshot: [e.g. 5.0.0] 107 | - Node version:[e.g 10.15.1] 108 | 109 | ### Security Bugs 110 | 111 | Please review our [Security Policy](./SECURITY.md). Please follow the instructions outlined in the policy. 112 | 113 | ## Getting in contact 114 | 115 | - Join our [Slack channel](https://one-amex.slack.com) request an invite [here](https://join.slack.com/t/one-amex/shared_invite/enQtOTA0MzEzODExODEwLTlmYzI1Y2U2ZDEwNWJjOTAxYTlmZTYzMjUyNzQyZTdmMWIwZGJmZDM2MDZmYzVjMDk5OWU4OGIwNjJjZWRhMjY) 116 | 117 | ## Coding conventions 118 | 119 | ### Git Commit Guidelines 120 | 121 | We follow [conventional commits](https://www.conventionalcommits.org/) 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. 122 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 American Express Travel Related Services Company, Inc. 190 | 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Jest Image Snapshot - One Amex 3 |

4 | 5 | [![npm](https://img.shields.io/npm/v/jest-image-snapshot)](https://www.npmjs.com/package/jest-image-snapshot) 6 | ![Health Check](https://github.com/americanexpress/jest-image-snapshot/workflows/Health%20Check/badge.svg) 7 | [![Mentioned in Awesome Jest](https://awesome.re/mentioned-badge.svg)](https://github.com/jest-community/awesome-jest) 8 | 9 | > Jest matcher that performs image comparisons using [pixelmatch](https://github.com/mapbox/pixelmatch) and behaves just like [Jest snapshots](https://facebook.github.io/jest/docs/snapshot-testing.html) do! Very useful for visual regression testing. 10 | 11 | ## 👩‍💻 Hiring 👨‍💻 12 | 13 | Want to get paid for your contributions to `jest-image-snapshot`? 14 | > Send your resume to oneamex.careers@aexp.com 15 | 16 | ## 📖 Table of Contents 17 | 18 | * [Features](#-features) 19 | * [Usage](#-usage) 20 | * [API](#-api) 21 | * [Contributing](#-contributing) 22 | 23 | ## ✨ Features 24 | 25 | * Take image snapshots of your application 26 | * Ability to compare snapshots from a baseline 27 | * Update snapshots when you're good with changes 28 | * Customize a difference threshold 29 | * Add a Gaussian blur for noise 30 | * Adjust the diff layout horizontal vs vertical 31 | 32 | ### How it works 33 | 34 | Given an image (Buffer instance with PNG image 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. Note that if `customSnapshotsDir` option is given then it will store baseline snapshot there instead. 35 | 36 | On subsequent test runs the matcher will compare the image being passed against the stored snapshot. 37 | 38 | 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). 39 | 40 | ### See it in action 41 | 42 | Typically this matcher is used for visual tests that run on a browser. For example let's say I finish working on a feature and want to write a test to prevent visual regressions: 43 | 44 | ```javascript 45 | ... 46 | it('renders correctly', async () => { 47 | const page = await browser.newPage(); 48 | await page.goto('https://localhost:3000'); 49 | const image = await page.screenshot(); 50 | 51 | expect(image).toMatchImageSnapshot(); 52 | }); 53 | ... 54 | ``` 55 | 56 | 57 | 58 | Then after a few days as I finish adding another feature to my component I notice one of my tests failing! 59 | 60 | 61 | 62 | Oh no! I must have introduced a regression! Let's see what the diff looks like to identify what I need to fix: 63 | 64 | 65 | 66 | And now that I know that I broke the card art I can fix it! 67 | 68 | Thanks `jest-image-snapshot`, that broken header would not have looked good in production! 69 | 70 | ## 🤹‍ Usage 71 | 72 | ### Installation 73 | 74 | ```bash 75 | npm i --save-dev jest-image-snapshot 76 | ``` 77 | 78 | Please note that `Jest` `>=20 <=29` is a peerDependency. `jest-image-snapshot` will **not** work with anything below Jest 20.x.x 79 | 80 | ### Invocation 81 | 82 | 1. Extend Jest's `expect` 83 | ```javascript 84 | const { toMatchImageSnapshot } = require('jest-image-snapshot'); 85 | 86 | expect.extend({ toMatchImageSnapshot }); 87 | ``` 88 | 89 | 2. Use `toMatchImageSnapshot()` in your tests! 90 | ```javascript 91 | it('should demonstrate this matcher`s usage', () => { 92 | ... 93 | expect(image).toMatchImageSnapshot(); 94 | }); 95 | ``` 96 | 97 | See [the examples](./examples/README.md) for more detailed usage or read about an example use case in the [American Express Technology Blog](https://americanexpress.io/smile-for-the-camera/) 98 | 99 | ## 🎛️ API 100 | 101 | `toMatchImageSnapshot()` takes an optional options object with the following properties: 102 | 103 | * `customDiffConfig`: Custom config passed to [pixelmatch](https://github.com/mapbox/pixelmatch#pixelmatchimg1-img2-output-width-height-options) (See options section) or [ssim.js](https://github.com/obartra/ssim/wiki/Usage#options) 104 | * Pixelmatch specific options 105 | * By default we have set the `threshold` to 0.01, you can increase that value by passing a customDiffConfig as demonstrated below. 106 | * Please note the `threshold` set in the `customDiffConfig` is the per pixel sensitivity threshold. For example with a source pixel colour of `#ffffff` (white) and a comparison pixel colour of `#fcfcfc` (really light grey) if you set the threshold to 0 then it would trigger a failure *on that pixel*. However if you were to use say 0.5 then it wouldn't, the colour difference would need to be much more extreme to trigger a failure on that pixel, say `#000000` (black) 107 | * SSIM specific options 108 | * By default we set `ssim` to 'bezkrovny'. It is the fastest option and best option most of the time. In cases where, higher precision is needed, this can be set to 'fast'. See [SSIM Performance Consideration](#ssim-performance-considerations) for a better understanding of how to use this feature. 109 | * `comparisonMethod`: (default: `pixelmatch`) (options `pixelmatch` or `ssim`) The method by which images are compared. `pixelmatch` does a pixel by pixel comparison, whereas `ssim` does a structural similarity comparison. `ssim` is a new experimental feature for jest-image-snapshot, but may become the default comparison method in the future. For a better understanding of how to use SSIM, see [Recommendations when using SSIM Comparison](#recommendations-when-using-ssim-comparison). 110 | * `customSnapshotsDir`: A custom absolute path of a directory to keep this snapshot in 111 | * `customDiffDir`: A custom absolute path of a directory to keep this diff in 112 | * `storeReceivedOnFailure`: (default: `false`) Store the received images seperately from the composed diff images on failure. This can be useful when updating baseline images from CI. 113 | * `customReceivedDir`: A custom absolute path of a directory to keep this received image in 114 | * `customReceivedPostfix`: A custom postfix which is added to the snapshot name of the received image, defaults to `-received` 115 | * `customSnapshotIdentifier`: A custom name to give this snapshot. If not provided one is computed automatically. When a function is provided it is called with an object containing `testPath`, `currentTestName`, `counter` and `defaultIdentifier` as its first argument. The function must return an identifier to use for the snapshot. If a path is given, the path will be created inside the snapshot/diff directories. 116 | * `diffDirection`: (default: `horizontal`) (options `horizontal` or `vertical`) Changes diff image layout direction 117 | * `onlyDiff`: (default: `false`) Either only include the difference between the baseline and the received image in the diff image, or include the 3 images (following the direction set by `diffDirection`). 118 | * `noColors`: Removes coloring from console output, useful if storing the results in a file 119 | * `failureThreshold`: (default `0`) Sets the threshold that would trigger a test failure based on the `failureThresholdType` selected. This is different to the `customDiffConfig.threshold` above, that is the per pixel failure threshold, this is the failure threshold for the entire comparison. 120 | * `failureThresholdType`: (default `pixel`) (options `percent` or `pixel`) Sets the type of threshold that would trigger a failure. 121 | * `updatePassedSnapshot`: (default `false`) Updates a snapshot even if it passed the threshold against the existing one. 122 | * `blur`: (default `0`) Applies Gaussian Blur on compared images, accepts radius in pixels as value. Useful when you have noise after scaling images per different resolutions on your target website, usually setting its value to 1-2 should be enough to solve that problem. 123 | * `runInProcess`: (default `false`) Runs the diff in process without spawning a child process. 124 | * `dumpDiffToConsole`: (default `false`) Will output base64 string of a diff image to console in case of failed tests (in addition to creating a diff image). This string can be copy-pasted to a browser address string to preview the diff for a failed test. 125 | * `dumpInlineDiffToConsole`: (default `false`) Will output the image to the terminal using iTerm's [Inline Images Protocol](https://iterm2.com/documentation-images.html). If the term is not compatible, it does the same thing as `dumpDiffToConsole`. 126 | * `allowSizeMismatch`: (default `false`) If set to true, the build will not fail when the screenshots to compare have different sizes. 127 | * `maxChildProcessBufferSizeInBytes`: (default `10 * 1024 * 1024`) Sets the max number of bytes for stdout/stderr when running `diff-snapshot` in a child process. 128 | * `runtimeHooksPath`: (default `undefined`) This needs to be set to a existing file, like `require.resolve('./runtimeHooksPath.cjs')`. This file can expose a few hooks: 129 | * `onBeforeWriteToDisc`: before saving any image to the disc, this function will be called (can be used to write EXIF data to images for instance) 130 | `onBeforeWriteToDisc: (arguments: { buffer: Buffer; destination: string; testPath: string; currentTestName: string }) => Buffer` 131 | 132 | ```javascript 133 | it('should demonstrate this matcher`s usage with a custom pixelmatch config', () => { 134 | ... 135 | const customConfig = { threshold: 0.5 }; 136 | expect(image).toMatchImageSnapshot({ 137 | customDiffConfig: customConfig, 138 | customSnapshotIdentifier: 'customSnapshotName', 139 | noColors: true 140 | }); 141 | }); 142 | ``` 143 | 144 | The failure threshold can be set in percent, in this case if the difference is over 1%. 145 | 146 | ```javascript 147 | it('should fail if there is more than a 1% difference', () => { 148 | ... 149 | expect(image).toMatchImageSnapshot({ 150 | failureThreshold: 0.01, 151 | failureThresholdType: 'percent' 152 | }); 153 | }); 154 | ``` 155 | 156 | Custom defaults can be set with a configurable extension. This will allow for customization of this module's defaults. For example, a 0% default threshold can be shared across all tests with the configuration below: 157 | 158 | ```javascript 159 | const { configureToMatchImageSnapshot } = require('jest-image-snapshot'); 160 | 161 | const customConfig = { threshold: 0 }; 162 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ 163 | customDiffConfig: customConfig, 164 | noColors: true, 165 | }); 166 | expect.extend({ toMatchImageSnapshot }); 167 | ``` 168 | 169 | ### jest.retryTimes() 170 | Jest supports [automatic retries on test failures](https://jestjs.io/docs/en/jest-object#jestretrytimes). This can be useful for browser screenshot tests which tend to have more frequent false positives. Note that when using jest.retryTimes you'll have to use a unique customSnapshotIdentifier as that's the only way to reliably identify snapshots. 171 | 172 | ### Removing Outdated Snapshots 173 | 174 | Unlike jest-managed snapshots, the images created by `jest-image-snapshot` will not be automatically removed by the `-u` flag if they are no longer needed. You can force `jest-image-snapshot` to remove the files by including the `outdated-snapshot-reporter` in your config and running with the environment variable `JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE`. 175 | 176 | ```json 177 | { 178 | "jest": { 179 | "reporters": [ 180 | "default", 181 | "jest-image-snapshot/src/outdated-snapshot-reporter.js" 182 | ] 183 | } 184 | } 185 | ``` 186 | 187 | **WARNING: Do not run a *partial* test suite with this flag as it may consider snapshots of tests that weren't run to be obsolete.** 188 | 189 | ```bash 190 | export JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE=1 191 | jest 192 | ``` 193 | 194 | ### Recommendations when using SSIM comparison 195 | Since SSIM calculates differences in structural similarity by building a moving 'window' over an images pixels, it does not particularly benefit from pixel count comparisons, especially when you factor in that it has a lot of floating point arithmetic in javascript. However, SSIM gains two key benefits over pixel by pixel comparison: 196 | - Reduced false positives (failing tests when the images look the same) 197 | - Higher sensitivity to actual changes in the image itself. 198 | 199 | Documentation supporting these claims can be found in the many analyses comparing SSIM to Peak Signal to Noise Ratio (PSNR). See [Wang, Z.; Simoncelli, E. P. (September 2008). "Maximum differentiation (MAD) competition: a methodology for comparing computational models of perceptual quantities"](https://ece.uwaterloo.ca/~z70wang/publications/MAD.pdf) and Zhang, L.; Zhang, L.; Mou, X.; Zhang, D. (September 2012). A comprehensive evaluation of full reference image quality assessment algorithms. 2012 19th IEEE International Conference on Image Processing. pp. 1477–1480. [Wikipedia](https://en.wikipedia.org/wiki/Structural_similarity) also provides many great reference sources describing the topic. 200 | 201 | As such, most users can benefit from setting a 1% or 0.01 threshold for any SSIM comparison. The below code shows a one line modification of the 1% threshold example. 202 | 203 | ```javascript 204 | it('should fail if there is more than a 1% difference (ssim)', () => { 205 | ... 206 | expect(image).toMatchImageSnapshot({ 207 | comparisonMethod: 'ssim', 208 | failureThreshold: 0.01, 209 | failureThresholdType: 'percent' 210 | }); 211 | }); 212 | ``` 213 | ### SSIM Performance Considerations 214 | The default SSIM comparison method used in the jest-image-snapshot implementation is 'bezkrovny' (as a `customDiffConfig` `{ssim: 'bezkrovny'}`). 215 | Bezkrovny is a special implementation of SSIM that is optimized for speed at a small, almost inconsequential change in accuracy. It gains this benefit by downsampling (or shrinking the original image) before performing the comparisons. 216 | This will provide the best combination of results and performance most of the time. When the need arises where higher accuracy is desired at the expense of time or a higher quality diff image is needed for debugging, 217 | this option can be changed to `{ssim: 'fast'}`. This uses the original SSIM algorithm described in Wang, et al. 2004 on "Image Quality Assessment: From Error Visibility to Structural Similarity" (https://github.com/obartra/ssim/blob/master/assets/ssim.pdf) optimized for javascript. 218 | 219 | The following is an example configuration for using `{ssim: 'fast'}` with toMatchImageSnapshot(). 220 | ```javascript. 221 | { 222 | comparisonMethod: 'ssim', 223 | customDiffConfig: { 224 | ssim: 'fast', 225 | }, 226 | failureThreshold: 0.01, 227 | failureThresholdType: 'percent' 228 | } 229 | ``` 230 | 231 | 232 | ### Recipes 233 | 234 | #### Upload diff images from failed tests 235 | 236 | [Example Image Upload Test Reporter](examples/image-reporter.js) 237 | 238 | If you are using jest-image-snapshot in an ephemeral environment (like a Continuous Integration server) where the file system does not persist, you might want a way to retrieve those images for diagnostics or historical reference. This example shows how to use a custom [Jest Reporter](https://facebook.github.io/jest/docs/en/configuration.html#reporters-array-modulename-modulename-options) that will run after every test, and if there were any images created because they failed the diff test, upload those images to an [AWS S3](https://aws.amazon.com/s3/) bucket. 239 | 240 | To enable this image reporter, add it to your `jest.config.js` "reporters" definition: 241 | 242 | ```javascript 243 | "reporters": [ "default", "/image-reporter.js" ] 244 | ``` 245 | 246 | #### Usage in TypeScript 247 | 248 | In TypeScript, you can use the [DefinitelyTyped](https://www.npmjs.com/package/@types/jest-image-snapshot) definition or declare `toMatchImageSnapshot` like the example below: 249 | 250 | _Note: This package is not maintained by the `jest-image-snapshot` maintainers so it may be out of date or inaccurate. Because of this, we do not officially support it._ 251 | 252 | 253 | ```typescript 254 | declare global { 255 | namespace jest { 256 | interface Matchers { 257 | toMatchImageSnapshot(): R 258 | } 259 | } 260 | } 261 | ``` 262 | 263 | #### Ignoring parts of the image snapshot if using [Puppeteer](https://github.com/GoogleChrome/puppeteer) 264 | 265 | If you want to ignore parts of the snapshot (for example some banners or other dynamic blocks) you can find DOM elements with Puppeteer and remove/modify them (setting visibility: hidden on block, if removing it breaks your layout, should help): 266 | 267 | ```javascript 268 | async function removeBanners(page){ 269 | await page.evaluate(() => { 270 | (document.querySelectorAll('.banner') || []).forEach(el => el.remove()); 271 | }); 272 | } 273 | 274 | ... 275 | it('renders correctly', async () => { 276 | const page = await browser.newPage(); 277 | await page.goto('https://localhost:3000'); 278 | 279 | await removeBanners(page); 280 | 281 | const image = await page.screenshot(); 282 | 283 | expect(image).toMatchImageSnapshot(); 284 | }); 285 | ... 286 | ``` 287 | 288 | ## 🏆 Contributing 289 | 290 | We welcome Your interest in the American Express Open Source Community on Github. 291 | Any Contributor to any Open Source Project managed by the American Express Open 292 | Source Community must accept and sign an Agreement indicating agreement to the 293 | terms below. Except for the rights granted in this Agreement to American Express 294 | and to recipients of software distributed by American Express, You reserve all 295 | right, title, and interest, if any, in and to Your Contributions. Please [fill 296 | out the Agreement](https://cla-assistant.io/americanexpress/jest-image-snapshot). 297 | 298 | Please feel free to open pull requests and see [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to get started contributing. 299 | 300 | ## 🗝️ License 301 | 302 | Any contributions made under this project will be governed by the [Apache License 303 | 2.0](https://github.com/americanexpress/jest-image-snapshot/blob/main/LICENSE.txt). 304 | 305 | ## 🗣️ Code of Conduct 306 | 307 | This project adheres to the [American Express Community Guidelines](https://github.com/americanexpress/jest-image-snapshot/wiki/Code-of-Conduct). 308 | By participating, you are expected to honor these guidelines. 309 | -------------------------------------------------------------------------------- /__tests__/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex/test" 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-6.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-obsolete-6-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-obsolete-6-snap.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-failures-writes-a-result-image-for-failing-tests-ssim-3-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-failures-writes-a-result-image-for-failing-tests-ssim-3-snap.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-matches-an-identical-snapshot-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-matches-an-identical-snapshot-1-snap.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-base-64-encoded-strings-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-base-64-encoded-strings-1-snap.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-typed-array-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-spec-js-to-match-image-snapshot-happy-path-should-work-with-typed-array-1-snap.png -------------------------------------------------------------------------------- /__tests__/__image_snapshots__/integration-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/__image_snapshots__/integration-update.png -------------------------------------------------------------------------------- /__tests__/__snapshots__/diff-snapshot.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`diff-snapshot diffImageToSnapshot should throw an error if an unknown threshold type is supplied 1`] = `"Unknown failureThresholdType: banana. Valid options are "pixel" or "percent"."`; 4 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`toMatchImageSnapshot dumpDiffToConsole imgSrcString is added to console message when dumpDiffToConsole is true 2`] = ` 4 | "Expected image to match or be a close match to snapshot but was 80% different from snapshot (600 differing pixels). 5 | See diff for details: path/to/result.png 6 | Or paste below image diff string to your browser\`s URL bar. 7 | pretendthisisanimagebase64string" 8 | `; 9 | 10 | exports[`toMatchImageSnapshot dumpDiffToConsole imgSrcString is not added to console by default 2`] = ` 11 | "Expected image to match or be a close match to snapshot but was 0% different from snapshot (0 differing pixels). 12 | See diff for details: path/to/result.png" 13 | `; 14 | 15 | exports[`toMatchImageSnapshot dumpInlineDiffToConsole falls back to dumpDiffToConsole if the terminal is unsupported 2`] = ` 16 | "Expected image to match or be a close match to snapshot but was 80% different from snapshot (600 differing pixels). 17 | See diff for details: path/to/result.png 18 | Or paste below image diff string to your browser\`s URL bar. 19 | pretendthisisanimagebase64string" 20 | `; 21 | 22 | exports[`toMatchImageSnapshot dumpInlineDiffToConsole uses Inline Image Protocol in iTerm 2`] = ` 23 | "Expected image to match or be a close match to snapshot but was 80% different from snapshot (600 differing pixels). 24 | See diff for details: path/to/result.png 25 | 26 | ]1337;File=name=cGF0aC90by9yZXN1bHQucG5n;inline=1;width=40:pretendthisisanimagebase64string 27 | 28 | " 29 | `; 30 | 31 | exports[`toMatchImageSnapshot dumpInlineDiffToConsole uses Inline Image Protocol when ENABLE_INLINE_DIFF is set 2`] = ` 32 | "Expected image to match or be a close match to snapshot but was 80% different from snapshot (600 differing pixels). 33 | See diff for details: path/to/result.png 34 | 35 | ]1337;File=name=cGF0aC90by9yZXN1bHQucG5n;inline=1;width=40:pretendthisisanimagebase64string 36 | 37 | " 38 | `; 39 | 40 | exports[`toMatchImageSnapshot passes diffImageToSnapshot everything it needs to create a snapshot and compare if needed 1`] = ` 41 | { 42 | "allowSizeMismatch": false, 43 | "blur": 0, 44 | "comparisonMethod": "pixelmatch", 45 | "currentTestName": "test", 46 | "customDiffConfig": {}, 47 | "diffDir": undefined, 48 | "diffDirection": "horizontal", 49 | "failureThreshold": 0, 50 | "failureThresholdType": "pixel", 51 | "maxChildProcessBufferSizeInBytes": 10485760, 52 | "onlyDiff": false, 53 | "receivedDir": undefined, 54 | "receivedImageBuffer": { 55 | "data": [ 56 | 166, 57 | 183, 58 | 173, 59 | 122, 60 | 119, 61 | 109, 62 | 134, 63 | 43, 64 | 34, 65 | 177, 66 | 169, 67 | 226, 68 | 153, 69 | 168, 70 | 30, 71 | 110, 72 | 231, 73 | 223, 74 | 122, 75 | ], 76 | "type": "Buffer", 77 | }, 78 | "receivedPostfix": undefined, 79 | "runtimeHooksPath": undefined, 80 | "snapshotIdentifier": "test-spec-js-test-1-snap", 81 | "snapshotsDir": "path/to/__image_snapshots__", 82 | "storeReceivedOnFailure": false, 83 | "testPath": "path/to/test.spec.js", 84 | "updatePassedSnapshot": false, 85 | "updateSnapshot": false, 86 | } 87 | `; 88 | 89 | exports[`toMatchImageSnapshot should fail when snapshot has a difference beyond allowed threshold 2`] = ` 90 | "Expected image to match or be a close match to snapshot but was 80% different from snapshot (600 differing pixels). 91 | See diff for details: path/to/result.png" 92 | `; 93 | 94 | exports[`toMatchImageSnapshot should not style error message if colors not supported 2`] = ` 95 | "Expected image to match or be a close match to snapshot but was 40% different from snapshot (600 differing pixels). 96 | See diff for details: path/to/result.png" 97 | `; 98 | 99 | exports[`toMatchImageSnapshot should style error message if colors supported 2`] = ` 100 | "Expected image to match or be a close match to snapshot but was 40% different from snapshot (600 differing pixels). 101 | See diff for details: path/to/result.png" 102 | `; 103 | 104 | exports[`toMatchImageSnapshot should throw an error if used with .not matcher 1`] = `"Jest: \`.not\` cannot be used with \`.toMatchImageSnapshot()\`."`; 105 | 106 | exports[`toMatchImageSnapshot should use noColors options if passed as false and style error message 2`] = ` 107 | "Expected image to match or be a close match to snapshot but was 40% different from snapshot (600 differing pixels). 108 | See diff for details: path/to/result.png" 109 | `; 110 | 111 | exports[`toMatchImageSnapshot should use noColors options if passed as true and not style error message 2`] = ` 112 | "Expected image to match or be a close match to snapshot but was 40% different from snapshot (600 differing pixels). 113 | See diff for details: path/to/result.png" 114 | `; 115 | 116 | exports[`toMatchImageSnapshot when retryTimes is set should throw an error when called without customSnapshotIdentifier 2`] = `"A unique customSnapshotIdentifier must be set when jest.retryTimes() is used"`; 117 | 118 | exports[`toMatchImageSnapshot when retryTimes is set should throw an error when called without customSnapshotIdentifier 4`] = `"A unique customSnapshotIdentifier must be set when jest.retryTimes() is used"`; 119 | -------------------------------------------------------------------------------- /__tests__/diff-snapshot.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | /* eslint-disable global-require */ 16 | const fs = require('fs'); 17 | const path = require('path'); 18 | 19 | describe('diff-snapshot', () => { 20 | beforeEach(() => { 21 | jest.resetModules(); 22 | jest.resetAllMocks(); 23 | }); 24 | 25 | describe('runDiffImageToSnapshot', () => { 26 | const mockSpawnSync = jest.fn(); 27 | const fakeRequest = { 28 | receivedImageBuffer: Buffer.from('abcdefg'), 29 | snapshotIdentifier: 'foo', 30 | snapshotsDir: 'bar', 31 | updateSnapshot: false, 32 | failureThreshold: 0, 33 | failureThresholdType: 'pixel', 34 | }; 35 | 36 | function setupTest(spawnReturn) { 37 | mockSpawnSync.mockReturnValue(spawnReturn); 38 | jest.mock('child_process', () => ({ spawnSync: mockSpawnSync })); 39 | const { runDiffImageToSnapshot } = require('../src/diff-snapshot'); 40 | return runDiffImageToSnapshot; 41 | } 42 | 43 | it('runs external process and returns result', () => { 44 | const runDiffImageToSnapshot = setupTest({ 45 | status: 0, output: [null, null, null, JSON.stringify({ add: true, updated: false })], 46 | }); 47 | 48 | expect(runDiffImageToSnapshot(fakeRequest)).toEqual({ add: true, updated: false }); 49 | 50 | expect(mockSpawnSync).toBeCalled(); 51 | }); 52 | 53 | it.each` 54 | spawnReturn 55 | ${{ status: 1 }} 56 | ${{ status: 1, error: {} }} 57 | ${{ status: 1, error: new Error() }} 58 | `( 59 | 'throws an Unknown Error when process returns a non-zero status $#', 60 | ({ spawnReturn }) => { 61 | const runDiffImageToSnapshot = setupTest(spawnReturn); 62 | 63 | expect(() => runDiffImageToSnapshot(fakeRequest)).toThrowError( 64 | new Error('Error running image diff: Unknown Error') 65 | ); 66 | } 67 | ); 68 | 69 | it('throws a helpful error if available', () => { 70 | const runDiffImageToSnapshot = setupTest({ 71 | status: 1, 72 | error: new Error('🦖'), 73 | }); 74 | expect(() => runDiffImageToSnapshot(fakeRequest)).toThrowError( 75 | new Error('Error running image diff: 🦖') 76 | ); 77 | }); 78 | }); 79 | 80 | describe('diffImageToSnapshot', () => { 81 | const mockSnapshotsDir = path.normalize('/path/to/snapshots'); 82 | const mockReceivedDir = path.normalize('/path/to/snapshots/__received_output__'); 83 | const mockDiffDir = path.normalize('/path/to/snapshots/__diff_output__'); 84 | const mockSnapshotIdentifier = 'id1'; 85 | const mockImagePath = './__tests__/stubs/TestImage.png'; 86 | const mockImageBuffer = fs.readFileSync(mockImagePath); 87 | const mockBigImagePath = './__tests__/stubs/TestImage150x150.png'; 88 | const mockBigImageBuffer = fs.readFileSync(mockBigImagePath); 89 | const mockFailImagePath = './__tests__/stubs/TestImageFailure.png'; 90 | const mockFailImageBuffer = fs.readFileSync(mockFailImagePath); 91 | const mockMkdirSync = jest.fn(); 92 | const mockWriteFileSync = jest.fn(); 93 | const mockPixelMatch = jest.fn(); 94 | const mockGlur = jest.fn(); 95 | 96 | function setupTest({ 97 | snapshotDirExists, 98 | snapshotExists, 99 | outputDirExists, 100 | defaultExists = true, 101 | pixelmatchResult = 0, 102 | }) { 103 | const mockFs = Object.assign({}, fs, { 104 | existsSync: jest.fn(), 105 | mkdirSync: mockMkdirSync, 106 | writeFileSync: mockWriteFileSync, 107 | readFileSync: jest.fn(), 108 | }); 109 | 110 | jest.mock('fs', () => mockFs); 111 | const { diffImageToSnapshot } = require('../src/diff-snapshot'); 112 | 113 | mockFs.existsSync.mockImplementation((p) => { 114 | switch (p) { 115 | case path.join(mockSnapshotsDir, `${mockSnapshotIdentifier}.png`): 116 | return snapshotExists; 117 | case mockDiffDir: 118 | return !!outputDirExists; 119 | case mockSnapshotsDir: 120 | return !!snapshotDirExists; 121 | default: 122 | return !!defaultExists; 123 | } 124 | }); 125 | mockFs.readFileSync.mockImplementation((p) => { 126 | const bn = path.basename(p); 127 | 128 | if (bn === 'id1.png' && snapshotExists) { 129 | return mockImageBuffer; 130 | } 131 | 132 | return null; 133 | }); 134 | 135 | jest.mock('pixelmatch', () => mockPixelMatch); 136 | mockPixelMatch.mockImplementation(() => pixelmatchResult); 137 | 138 | jest.mock('glur', () => mockGlur); 139 | 140 | return diffImageToSnapshot; 141 | } 142 | 143 | it('should run comparison if there is already a snapshot stored and updateSnapshot flag is not set', () => { 144 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 145 | const result = diffImageToSnapshot({ 146 | receivedImageBuffer: mockImageBuffer, 147 | snapshotIdentifier: mockSnapshotIdentifier, 148 | snapshotsDir: mockSnapshotsDir, 149 | receivedDir: mockReceivedDir, 150 | diffDir: mockDiffDir, 151 | updateSnapshot: false, 152 | failureThreshold: 0, 153 | failureThresholdType: 'pixel', 154 | }); 155 | 156 | expect(result).toMatchObject({ 157 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 158 | diffRatio: 0, 159 | diffPixelCount: 0, 160 | pass: true, 161 | }); 162 | }); 163 | 164 | it('it should not write a diff if a test passes', () => { 165 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 0 }); 166 | const result = diffImageToSnapshot({ 167 | receivedImageBuffer: mockImageBuffer, 168 | snapshotIdentifier: mockSnapshotIdentifier, 169 | snapshotsDir: mockSnapshotsDir, 170 | receivedDir: mockReceivedDir, 171 | diffDir: mockDiffDir, 172 | updateSnapshot: false, 173 | failureThreshold: 0, 174 | failureThresholdType: 'pixel', 175 | }); 176 | 177 | expect(result).toMatchObject({ 178 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 179 | diffRatio: 0, 180 | diffPixelCount: 0, 181 | pass: true, 182 | }); 183 | 184 | // Check that that it did not attempt to write a diff 185 | expect(mockWriteFileSync.mock.calls).toEqual([]); 186 | }); 187 | 188 | it('should write a diff image and imgSrcString if the test fails', () => { 189 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 5000 }); 190 | const result = diffImageToSnapshot({ 191 | receivedImageBuffer: mockFailImageBuffer, 192 | snapshotIdentifier: mockSnapshotIdentifier, 193 | snapshotsDir: mockSnapshotsDir, 194 | receivedDir: mockReceivedDir, 195 | diffDir: mockDiffDir, 196 | updateSnapshot: false, 197 | failureThreshold: 0, 198 | failureThresholdType: 'pixel', 199 | }); 200 | 201 | expect(result).toMatchObject({ 202 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 203 | diffRatio: 0.5, 204 | diffPixelCount: 5000, 205 | pass: false, 206 | }); 207 | 208 | const isBase64ImgStr = result.imgSrcString.includes('data:image/png;base64,iV'); 209 | expect(isBase64ImgStr).toBe(true); 210 | 211 | expect(mockPixelMatch).toHaveBeenCalledTimes(1); 212 | expect(mockPixelMatch).toHaveBeenCalledWith( 213 | expect.any(Buffer), 214 | expect.any(Buffer), 215 | expect.any(Buffer), 216 | 100, 217 | 100, 218 | { threshold: 0.01 } 219 | ); 220 | 221 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 222 | }); 223 | 224 | it('should write a received image if the test fails and storeReceivedOnFailure = true', () => { 225 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 5000 }); 226 | const result = diffImageToSnapshot({ 227 | receivedImageBuffer: mockFailImageBuffer, 228 | snapshotIdentifier: mockSnapshotIdentifier, 229 | snapshotsDir: mockSnapshotsDir, 230 | storeReceivedOnFailure: true, 231 | receivedDir: mockReceivedDir, 232 | diffDir: mockDiffDir, 233 | updateSnapshot: false, 234 | failureThreshold: 0, 235 | failureThresholdType: 'pixel', 236 | }); 237 | 238 | expect(result).toMatchObject({ 239 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 240 | receivedSnapshotPath: path.join(mockSnapshotsDir, '__received_output__', 'id1-received.png'), 241 | diffRatio: 0.5, 242 | diffPixelCount: 5000, 243 | pass: false, 244 | }); 245 | 246 | expect(mockWriteFileSync).toHaveBeenCalledTimes(2); 247 | }); 248 | 249 | it('should write a received image with custom postfix if customReceivedPostfix is set', () => { 250 | const diffImageToSnapshot = setupTest({ 251 | snapshotExists: true, 252 | pixelmatchResult: 5000, 253 | }); 254 | const result = diffImageToSnapshot({ 255 | receivedImageBuffer: mockFailImageBuffer, 256 | snapshotIdentifier: mockSnapshotIdentifier, 257 | snapshotsDir: mockSnapshotsDir, 258 | storeReceivedOnFailure: true, 259 | receivedDir: mockReceivedDir, 260 | receivedPostfix: '-new', 261 | diffDir: mockDiffDir, 262 | updateSnapshot: false, 263 | failureThreshold: 0, 264 | failureThresholdType: 'pixel', 265 | }); 266 | 267 | expect(result).toMatchObject({ 268 | diffOutputPath: path.join( 269 | mockSnapshotsDir, 270 | '__diff_output__', 271 | 'id1-diff.png' 272 | ), 273 | receivedSnapshotPath: path.join( 274 | mockSnapshotsDir, 275 | '__received_output__', 276 | 'id1-new.png' 277 | ), 278 | diffRatio: 0.5, 279 | diffPixelCount: 5000, 280 | pass: false, 281 | }); 282 | 283 | expect(mockWriteFileSync).toHaveBeenCalledTimes(2); 284 | }); 285 | 286 | it('should not write a received image if the test fails and storeReceivedOnFailure = false', () => { 287 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 5000 }); 288 | const result = diffImageToSnapshot({ 289 | receivedImageBuffer: mockFailImageBuffer, 290 | snapshotIdentifier: mockSnapshotIdentifier, 291 | snapshotsDir: mockSnapshotsDir, 292 | storeReceivedOnFailure: false, 293 | receivedDir: mockReceivedDir, 294 | diffDir: mockDiffDir, 295 | updateSnapshot: false, 296 | failureThreshold: 0, 297 | failureThresholdType: 'pixel', 298 | }); 299 | 300 | expect(result).toMatchObject({ 301 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 302 | diffRatio: 0.5, 303 | diffPixelCount: 5000, 304 | pass: false, 305 | }); 306 | 307 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 308 | }); 309 | 310 | it('should fail if image passed is a different size', () => { 311 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 5000 }); 312 | const result = diffImageToSnapshot({ 313 | receivedImageBuffer: mockBigImageBuffer, 314 | snapshotIdentifier: mockSnapshotIdentifier, 315 | snapshotsDir: mockSnapshotsDir, 316 | receivedDir: mockReceivedDir, 317 | diffDir: mockDiffDir, 318 | updateSnapshot: false, 319 | failureThreshold: 0, 320 | failureThresholdType: 'pixel', 321 | }); 322 | 323 | expect(result).toMatchObject({ 324 | diffOutputPath: path.join(mockSnapshotsDir, '__diff_output__', 'id1-diff.png'), 325 | pass: false, 326 | }); 327 | expect(mockPixelMatch).toHaveBeenCalledTimes(1); 328 | expect(mockPixelMatch).toHaveBeenCalledWith( 329 | expect.any(Buffer), 330 | expect.any(Buffer), 331 | expect.any(Buffer), 332 | 150, 333 | 150, 334 | { threshold: 0.01 } 335 | ); 336 | 337 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 338 | }); 339 | 340 | it('should pass <= failureThreshold pixel', () => { 341 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 250 }); 342 | const result = diffImageToSnapshot({ 343 | receivedImageBuffer: mockFailImageBuffer, 344 | snapshotIdentifier: mockSnapshotIdentifier, 345 | snapshotsDir: mockSnapshotsDir, 346 | receivedDir: mockReceivedDir, 347 | diffDir: mockDiffDir, 348 | updateSnapshot: false, 349 | failureThreshold: 250, 350 | failureThresholdType: 'pixel', 351 | }); 352 | 353 | expect(result.pass).toBe(true); 354 | expect(result.diffPixelCount).toBe(250); 355 | expect(result.diffRatio).toBe(0.025); 356 | }); 357 | 358 | it('should pass with allowSizeMismatch: true if image passed is a different size but <= failureThreshold pixel', () => { 359 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 250 }); 360 | const result = diffImageToSnapshot({ 361 | receivedImageBuffer: mockBigImageBuffer, 362 | snapshotIdentifier: mockSnapshotIdentifier, 363 | snapshotsDir: mockSnapshotsDir, 364 | receivedDir: mockReceivedDir, 365 | diffDir: mockDiffDir, 366 | updateSnapshot: false, 367 | failureThreshold: 250, 368 | failureThresholdType: 'pixel', 369 | allowSizeMismatch: true, 370 | }); 371 | 372 | expect(result.pass).toBe(true); 373 | expect(result.diffSize).toBe(true); 374 | expect(result.diffPixelCount).toBe(250); 375 | expect(result.diffRatio).toBe(0.1 / 9); 376 | }); 377 | 378 | it('should fail with allowSizeMismatch: true if image passed is a different size but > failureThreshold pixel', () => { 379 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 250 }); 380 | const result = diffImageToSnapshot({ 381 | receivedImageBuffer: mockBigImageBuffer, 382 | snapshotIdentifier: mockSnapshotIdentifier, 383 | snapshotsDir: mockSnapshotsDir, 384 | receivedDir: mockReceivedDir, 385 | diffDir: mockDiffDir, 386 | updateSnapshot: false, 387 | failureThreshold: 0, 388 | failureThresholdType: 'pixel', 389 | allowSizeMismatch: true, 390 | }); 391 | 392 | expect(result.pass).toBe(false); 393 | expect(result.diffSize).toBe(true); 394 | expect(result.diffPixelCount).toBe(250); 395 | expect(result.diffRatio).toBe(0.1 / 9); 396 | }); 397 | 398 | it('should pass = image checksums', () => { 399 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 0 }); 400 | const result = diffImageToSnapshot({ 401 | receivedImageBuffer: mockImageBuffer, 402 | snapshotIdentifier: mockSnapshotIdentifier, 403 | snapshotsDir: mockSnapshotsDir, 404 | receivedDir: mockReceivedDir, 405 | diffDir: mockDiffDir, 406 | updateSnapshot: false, 407 | failureThreshold: 0, 408 | failureThresholdType: 'pixel', 409 | }); 410 | 411 | expect(result.pass).toBe(true); 412 | expect(result.diffPixelCount).toBe(0); 413 | expect(result.diffRatio).toBe(0); 414 | }); 415 | 416 | it('should run pixelmatch != image checksums', () => { 417 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 250 }); 418 | const result = diffImageToSnapshot({ 419 | receivedImageBuffer: mockFailImageBuffer, 420 | snapshotIdentifier: mockSnapshotIdentifier, 421 | snapshotsDir: mockSnapshotsDir, 422 | receivedDir: mockReceivedDir, 423 | diffDir: mockDiffDir, 424 | updateSnapshot: false, 425 | failureThreshold: 250, 426 | failureThresholdType: 'pixel', 427 | }); 428 | 429 | expect(mockPixelMatch).toHaveBeenCalledTimes(1); 430 | expect(result.pass).toBe(true); 431 | expect(result.diffPixelCount).toBe(250); 432 | expect(result.diffRatio).toBe(0.025); 433 | }); 434 | 435 | it('should fail > failureThreshold pixel', () => { 436 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 251 }); 437 | const result = diffImageToSnapshot({ 438 | receivedImageBuffer: mockFailImageBuffer, 439 | snapshotIdentifier: mockSnapshotIdentifier, 440 | snapshotsDir: mockSnapshotsDir, 441 | receivedDir: mockReceivedDir, 442 | diffDir: mockDiffDir, 443 | updateSnapshot: false, 444 | failureThreshold: 250, 445 | failureThresholdType: 'pixel', 446 | }); 447 | 448 | expect(result.pass).toBe(false); 449 | expect(result.diffPixelCount).toBe(251); 450 | expect(result.diffRatio).toBe(0.0251); 451 | }); 452 | 453 | it('should pass <= failureThreshold percent', () => { 454 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 250 }); 455 | const result = diffImageToSnapshot({ 456 | receivedImageBuffer: mockFailImageBuffer, 457 | snapshotIdentifier: mockSnapshotIdentifier, 458 | snapshotsDir: mockSnapshotsDir, 459 | receivedDir: mockReceivedDir, 460 | diffDir: mockDiffDir, 461 | updateSnapshot: false, 462 | failureThreshold: 0.025, 463 | failureThresholdType: 'percent', 464 | }); 465 | 466 | expect(result.pass).toBe(true); 467 | expect(result.diffPixelCount).toBe(250); 468 | expect(result.diffRatio).toBe(0.025); 469 | }); 470 | 471 | it('should fail > failureThreshold percent', () => { 472 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 251 }); 473 | const result = diffImageToSnapshot({ 474 | receivedImageBuffer: mockFailImageBuffer, 475 | snapshotIdentifier: mockSnapshotIdentifier, 476 | snapshotsDir: mockSnapshotsDir, 477 | receivedDir: mockReceivedDir, 478 | diffDir: mockDiffDir, 479 | updateSnapshot: false, 480 | failureThreshold: 0.025, 481 | failureThresholdType: 'percent', 482 | }); 483 | 484 | expect(result.pass).toBe(false); 485 | expect(result.diffPixelCount).toBe(251); 486 | expect(result.diffRatio).toBe(0.0251); 487 | }); 488 | 489 | it('should take the default diff config', () => { 490 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 491 | 492 | diffImageToSnapshot({ 493 | receivedImageBuffer: mockImageBuffer, 494 | snapshotIdentifier: mockSnapshotIdentifier, 495 | snapshotsDir: mockSnapshotsDir, 496 | diffDir: mockDiffDir, 497 | updateSnapshot: false, 498 | failureThreshold: 0, 499 | failureThresholdType: 'pixel', 500 | }); 501 | 502 | // Check that pixelmatch was not called 503 | expect(mockPixelMatch).toHaveBeenCalledWith( 504 | expect.any(Object), // buffer data 505 | expect.any(Object), // buffer data 506 | expect.any(Object), // buffer data 507 | expect.any(Number), // image width 508 | expect.any(Number), // image height 509 | { threshold: 0.01 } 510 | ); 511 | }); 512 | 513 | it('should merge custom configuration with default configuration if custom config is passed', () => { 514 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 515 | 516 | diffImageToSnapshot({ 517 | receivedImageBuffer: mockImageBuffer, 518 | snapshotIdentifier: mockSnapshotIdentifier, 519 | snapshotsDir: mockSnapshotsDir, 520 | receivedDir: mockReceivedDir, 521 | diffDir: mockDiffDir, 522 | updateSnapshot: false, 523 | customDiffConfig: { 524 | foo: 'bar', 525 | }, 526 | failureThreshold: 0, 527 | failureThresholdType: 'pixel', 528 | }); 529 | 530 | // Check that pixelmatch was not called 531 | expect(mockPixelMatch).toHaveBeenCalledWith( 532 | expect.any(Object), // buffer data 533 | expect.any(Object), // buffer data 534 | expect.any(Object), // buffer data 535 | expect.any(Number), // image width 536 | expect.any(Number), // image height 537 | { foo: 'bar', threshold: 0.01 } 538 | ); 539 | }); 540 | 541 | it('should create diff output directory if there is not one already and test is failing', () => { 542 | const diffImageToSnapshot = setupTest({ 543 | snapshotExists: true, 544 | outputDirExists: false, 545 | pixelmatchResult: 100, 546 | }); 547 | diffImageToSnapshot({ 548 | receivedImageBuffer: mockFailImageBuffer, 549 | snapshotIdentifier: mockSnapshotIdentifier, 550 | snapshotsDir: mockSnapshotsDir, 551 | receivedDir: mockReceivedDir, 552 | diffDir: mockDiffDir, 553 | failureThreshold: 0, 554 | failureThresholdType: 'pixel', 555 | }); 556 | 557 | expect(mockMkdirSync).toHaveBeenCalledWith(path.join(mockSnapshotsDir, '__diff_output__'), { recursive: true }); 558 | }); 559 | 560 | it('should create diff output sub-directory if there is not one already and test is failing', () => { 561 | const diffImageToSnapshot = setupTest({ 562 | snapshotExists: true, 563 | outputDirExists: false, 564 | pixelmatchResult: 100, 565 | }); 566 | 567 | diffImageToSnapshot({ 568 | receivedImageBuffer: mockFailImageBuffer, 569 | snapshotIdentifier: path.join('parent', mockSnapshotIdentifier), 570 | snapshotsDir: mockSnapshotsDir, 571 | receivedDir: mockReceivedDir, 572 | diffDir: mockDiffDir, 573 | failureThreshold: 0, 574 | failureThresholdType: 'pixel', 575 | }); 576 | 577 | expect(mockMkdirSync).toHaveBeenCalledWith(path.join(mockSnapshotsDir, '__diff_output__', 'parent'), { recursive: true }); 578 | }); 579 | 580 | it('should not create diff output directory if test passed', () => { 581 | const diffImageToSnapshot = setupTest({ snapshotExists: true, outputDirExists: false }); 582 | diffImageToSnapshot({ 583 | receivedImageBuffer: mockImageBuffer, 584 | snapshotIdentifier: mockSnapshotIdentifier, 585 | snapshotsDir: mockSnapshotsDir, 586 | receivedDir: mockReceivedDir, 587 | diffDir: mockDiffDir, 588 | updateSnapshot: false, 589 | failureThreshold: 0, 590 | failureThresholdType: 'pixel', 591 | }); 592 | 593 | expect(mockMkdirSync).not.toHaveBeenCalled(); 594 | }); 595 | 596 | it('should not create diff output directory if there is one there already', () => { 597 | const diffImageToSnapshot = setupTest({ snapshotExists: true, outputDirExists: true }); 598 | diffImageToSnapshot({ 599 | receivedImageBuffer: mockImageBuffer, 600 | snapshotIdentifier: mockSnapshotIdentifier, 601 | snapshotsDir: mockSnapshotsDir, 602 | receivedDir: mockReceivedDir, 603 | diffDir: mockDiffDir, 604 | updateSnapshot: false, 605 | failureThreshold: 0, 606 | failureThresholdType: 'pixel', 607 | }); 608 | 609 | expect(mockMkdirSync).not.toHaveBeenCalledWith(path.join(mockSnapshotsDir, '__diff_output__')); 610 | }); 611 | 612 | it('should create snapshots directory if there is not one already', () => { 613 | const diffImageToSnapshot = setupTest({ snapshotExists: true, snapshotDirExists: false }); 614 | diffImageToSnapshot({ 615 | receivedImageBuffer: mockImageBuffer, 616 | snapshotIdentifier: mockSnapshotIdentifier, 617 | snapshotsDir: mockSnapshotsDir, 618 | receivedDir: mockReceivedDir, 619 | diffDir: mockDiffDir, 620 | updateSnapshot: true, 621 | updatePassedSnapshot: true, 622 | failureThreshold: 0, 623 | failureThresholdType: 'pixel', 624 | }); 625 | 626 | expect(mockMkdirSync).toHaveBeenCalledWith(mockSnapshotsDir, { recursive: true }); 627 | }); 628 | 629 | it('should create snapshots sub-directory if there is not one already', () => { 630 | const diffImageToSnapshot = setupTest({ snapshotExists: true, snapshotDirExists: false }); 631 | diffImageToSnapshot({ 632 | receivedImageBuffer: mockImageBuffer, 633 | snapshotIdentifier: path.join('parent', mockSnapshotIdentifier), 634 | snapshotsDir: mockSnapshotsDir, 635 | receivedDir: mockReceivedDir, 636 | diffDir: mockDiffDir, 637 | updateSnapshot: true, 638 | updatePassedSnapshot: true, 639 | failureThreshold: 0, 640 | failureThresholdType: 'pixel', 641 | }); 642 | 643 | expect(mockMkdirSync).toHaveBeenCalledWith(path.join(mockSnapshotsDir, 'parent'), { recursive: true }); 644 | }); 645 | 646 | it('should not create snapshots directory if there already is one', () => { 647 | const diffImageToSnapshot = setupTest({ snapshotExists: true, snapshotDirExists: true }); 648 | diffImageToSnapshot({ 649 | receivedImageBuffer: mockImageBuffer, 650 | snapshotIdentifier: mockSnapshotIdentifier, 651 | snapshotsDir: mockSnapshotsDir, 652 | receivedDir: mockReceivedDir, 653 | diffDir: mockDiffDir, 654 | updateSnapshot: true, 655 | failureThreshold: 0, 656 | failureThresholdType: 'pixel', 657 | }); 658 | 659 | expect(mockMkdirSync).not.toHaveBeenCalledWith(mockSnapshotsDir); 660 | }); 661 | 662 | it('should create snapshot in __image_snapshots__ directory if there is not a snapshot created yet', () => { 663 | const diffImageToSnapshot = setupTest({ snapshotExists: false, snapshotDirExists: false }); 664 | diffImageToSnapshot({ 665 | receivedImageBuffer: mockImageBuffer, 666 | snapshotIdentifier: mockSnapshotIdentifier, 667 | snapshotsDir: mockSnapshotsDir, 668 | receivedDir: mockReceivedDir, 669 | diffDir: mockDiffDir, 670 | updateSnapshot: false, 671 | failureThreshold: 0, 672 | failureThresholdType: 'pixel', 673 | }); 674 | 675 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 676 | expect(mockWriteFileSync).toHaveBeenCalledWith(path.join(mockSnapshotsDir, `${mockSnapshotIdentifier}.png`), mockImageBuffer); 677 | }); 678 | 679 | it('should return updated flag if snapshot was updated', () => { 680 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 681 | const diffResult = diffImageToSnapshot({ 682 | receivedImageBuffer: mockImageBuffer, 683 | snapshotIdentifier: mockSnapshotIdentifier, 684 | snapshotsDir: mockSnapshotsDir, 685 | receivedDir: mockReceivedDir, 686 | diffDir: mockDiffDir, 687 | updateSnapshot: true, 688 | updatePassedSnapshot: true, 689 | failureThreshold: 0, 690 | failureThresholdType: 'pixel', 691 | }); 692 | 693 | expect(diffResult).toHaveProperty('updated', true); 694 | }); 695 | 696 | it('should return added flag if snapshot was added', () => { 697 | const diffImageToSnapshot = setupTest({ snapshotExists: false }); 698 | const diffResult = diffImageToSnapshot({ 699 | receivedImageBuffer: mockImageBuffer, 700 | snapshotIdentifier: mockSnapshotIdentifier, 701 | snapshotsDir: mockSnapshotsDir, 702 | receivedDir: mockReceivedDir, 703 | diffDir: mockDiffDir, 704 | diffDirection: 'vertical', 705 | updateSnapshot: false, 706 | failureThreshold: 0, 707 | failureThresholdType: 'pixel', 708 | }); 709 | 710 | expect(diffResult).toHaveProperty('added', true); 711 | expect(mockWriteFileSync).toHaveBeenCalledWith( 712 | path.join(mockSnapshotsDir, 'id1.png'), 713 | expect.any(Buffer) 714 | ); 715 | }); 716 | 717 | it('should return path to comparison output image if a comparison was performed', () => { 718 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 719 | const diffResult = diffImageToSnapshot({ 720 | receivedImageBuffer: mockImageBuffer, 721 | snapshotIdentifier: mockSnapshotIdentifier, 722 | snapshotsDir: mockSnapshotsDir, 723 | receivedDir: mockReceivedDir, 724 | diffDir: mockDiffDir, 725 | updateSnapshot: false, 726 | failureThreshold: 0, 727 | failureThresholdType: 'pixel', 728 | }); 729 | 730 | expect(diffResult).toHaveProperty('diffOutputPath', path.join(mockSnapshotsDir, '__diff_output__', `${mockSnapshotIdentifier}-diff.png`)); 731 | }); 732 | 733 | describe('diffImageToSnapshot', () => { 734 | it('should fail if snapshot already exists', () => { 735 | const { diffImageToSnapshot } = setupTest({ snapshotExists: true }); 736 | const options = { 737 | receivedImageBuffer: mockFailImageBuffer, 738 | snapshotIdentifier: mockSnapshotIdentifier, 739 | snapshotsDir: mockSnapshotsDir, 740 | updateSnapshot: false, 741 | }; 742 | 743 | expect(() => { 744 | diffImageToSnapshot(options); 745 | }).toThrow(); 746 | }); 747 | }); 748 | 749 | it('should throw an error if an unknown threshold type is supplied', () => { 750 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 751 | 752 | expect(() => { 753 | diffImageToSnapshot({ 754 | receivedImageBuffer: mockFailImageBuffer, 755 | snapshotIdentifier: mockSnapshotIdentifier, 756 | snapshotsDir: mockSnapshotsDir, 757 | receivedDir: mockReceivedDir, 758 | diffDir: mockDiffDir, 759 | updateSnapshot: false, 760 | failureThreshold: 0, 761 | failureThresholdType: 'banana', 762 | }); 763 | }).toThrowErrorMatchingSnapshot(); 764 | }); 765 | 766 | it('should not write a file if updatePassedSnapshot is false', () => { 767 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 768 | 769 | const diffResult = diffImageToSnapshot({ 770 | receivedImageBuffer: mockImageBuffer, 771 | snapshotIdentifier: mockSnapshotIdentifier, 772 | snapshotsDir: mockSnapshotsDir, 773 | receivedDir: mockReceivedDir, 774 | diffDir: mockDiffDir, 775 | updateSnapshot: true, 776 | updatePassedSnapshot: false, 777 | failureThreshold: 0, 778 | failureThresholdType: 'pixel', 779 | }); 780 | 781 | expect(mockWriteFileSync).not.toHaveBeenCalled(); 782 | expect(diffResult).toHaveProperty('pass', true); 783 | }); 784 | 785 | it('should write a file if updatePassedSnapshot is true on passing test', () => { 786 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 787 | 788 | const diffResult = diffImageToSnapshot({ 789 | receivedImageBuffer: mockImageBuffer, 790 | snapshotIdentifier: mockSnapshotIdentifier, 791 | snapshotsDir: mockSnapshotsDir, 792 | receivedDir: mockReceivedDir, 793 | diffDir: mockDiffDir, 794 | updateSnapshot: true, 795 | updatePassedSnapshot: true, 796 | failureThreshold: 0, 797 | failureThresholdType: 'pixel', 798 | }); 799 | 800 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 801 | expect(diffResult).toHaveProperty('updated', true); 802 | }); 803 | 804 | it('should update snapshot on failure if updatePassedSnapshot is false', () => { 805 | const diffImageToSnapshot = setupTest({ snapshotExists: true, pixelmatchResult: 500 }); 806 | 807 | const diffResult = diffImageToSnapshot({ 808 | receivedImageBuffer: mockFailImageBuffer, 809 | snapshotIdentifier: mockSnapshotIdentifier, 810 | snapshotsDir: mockSnapshotsDir, 811 | receivedDir: mockReceivedDir, 812 | diffDir: mockDiffDir, 813 | updateSnapshot: true, 814 | updatePassedSnapshot: false, 815 | failureThreshold: 0, 816 | failureThresholdType: 'pixel', 817 | }); 818 | 819 | expect(mockWriteFileSync).toHaveBeenCalledTimes(1); 820 | expect(diffResult).toHaveProperty('updated', true); 821 | }); 822 | 823 | it('should not run glur on compared images when no value for blur param is provided', () => { 824 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 825 | const result = diffImageToSnapshot({ 826 | receivedImageBuffer: mockFailImageBuffer, 827 | snapshotIdentifier: mockSnapshotIdentifier, 828 | snapshotsDir: mockSnapshotsDir, 829 | receivedDir: mockReceivedDir, 830 | diffDir: mockDiffDir, 831 | updateSnapshot: false, 832 | failureThreshold: 0, 833 | failureThresholdType: 'pixel', 834 | }); 835 | 836 | expect(mockGlur).not.toHaveBeenCalled(); 837 | expect(result.pass).toBe(true); 838 | }); 839 | 840 | it('should run glur on compared images when value for blur param is provided', () => { 841 | const diffImageToSnapshot = setupTest({ snapshotExists: true }); 842 | const result = diffImageToSnapshot({ 843 | receivedImageBuffer: mockFailImageBuffer, 844 | snapshotIdentifier: mockSnapshotIdentifier, 845 | snapshotsDir: mockSnapshotsDir, 846 | receivedDir: mockReceivedDir, 847 | diffDir: mockDiffDir, 848 | updateSnapshot: false, 849 | failureThreshold: 0, 850 | failureThresholdType: 'pixel', 851 | blur: 2, 852 | }); 853 | 854 | expect(mockGlur).toHaveBeenCalledTimes(2); 855 | expect(mockGlur).toHaveBeenCalledWith( 856 | expect.any(Buffer), 857 | 100, 858 | 100, 859 | 2 860 | ); 861 | expect(result.pass).toBe(true); 862 | }); 863 | 864 | it('should populate imgSrcString if test failed', () => { 865 | const diffImageToSnapshot = setupTest({ 866 | snapshotExists: true, 867 | outputDirExists: false, 868 | pixelmatchResult: 100, 869 | }); 870 | diffImageToSnapshot({ 871 | receivedImageBuffer: mockFailImageBuffer, 872 | snapshotIdentifier: mockSnapshotIdentifier, 873 | snapshotsDir: mockSnapshotsDir, 874 | receivedDir: mockReceivedDir, 875 | diffDir: mockDiffDir, 876 | failureThreshold: 0, 877 | failureThresholdType: 'pixel', 878 | }); 879 | 880 | expect(mockMkdirSync).toHaveBeenCalledWith(path.join(mockSnapshotsDir, '__diff_output__'), { recursive: true }); 881 | }); 882 | 883 | it('should pass data to a file mentioned by runtimeHooksPath when writing files', () => { 884 | jest.doMock(require.resolve('./stubs/runtimeHooksPath.js'), () => ({ 885 | onBeforeWriteToDisc: jest.fn(({ buffer }) => buffer), 886 | })); 887 | const { onBeforeWriteToDisc } = require('./stubs/runtimeHooksPath'); 888 | 889 | const diffImageToSnapshot = setupTest({ snapshotExists: false, pixelmatchResult: 0 }); 890 | const result = diffImageToSnapshot({ 891 | receivedImageBuffer: mockImageBuffer, 892 | snapshotIdentifier: mockSnapshotIdentifier, 893 | snapshotsDir: mockSnapshotsDir, 894 | receivedDir: mockReceivedDir, 895 | diffDir: mockDiffDir, 896 | failureThreshold: 0, 897 | failureThresholdType: 'pixel', 898 | runtimeHooksPath: require.resolve('./stubs/runtimeHooksPath.js'), 899 | testPath: 'test.spec.js', 900 | currentTestName: 'test a', 901 | }); 902 | 903 | expect(result).toMatchObject({ 904 | added: true, 905 | }); 906 | 907 | expect(onBeforeWriteToDisc).toHaveBeenCalledTimes(1); 908 | expect(onBeforeWriteToDisc).toHaveBeenCalledWith({ 909 | buffer: mockImageBuffer, 910 | destination: path.normalize('/path/to/snapshots/id1.png'), 911 | testPath: 'test.spec.js', 912 | currentTestName: 'test a', 913 | }); 914 | }); 915 | 916 | it('should work even when runtimeHooksPath is invalid', () => { 917 | const diffImageToSnapshot = setupTest({ snapshotExists: false, pixelmatchResult: 0 }); 918 | expect(() => diffImageToSnapshot({ 919 | receivedImageBuffer: mockImageBuffer, 920 | snapshotIdentifier: mockSnapshotIdentifier, 921 | snapshotsDir: mockSnapshotsDir, 922 | receivedDir: mockReceivedDir, 923 | diffDir: mockDiffDir, 924 | failureThreshold: 0, 925 | failureThresholdType: 'pixel', 926 | runtimeHooksPath: './non-existing-file.js', 927 | })).toThrowError( 928 | new Error("Couldn't import ./non-existing-file.js: Cannot find module './non-existing-file.js' from 'src/diff-snapshot.js'") 929 | ); 930 | 931 | jest.doMock(require.resolve('./stubs/runtimeHooksPath.js'), () => ({ 932 | onBeforeWriteToDisc: () => { 933 | throw new Error('wrong'); 934 | }, 935 | })); 936 | expect(() => diffImageToSnapshot({ 937 | receivedImageBuffer: mockImageBuffer, 938 | snapshotIdentifier: mockSnapshotIdentifier, 939 | snapshotsDir: mockSnapshotsDir, 940 | receivedDir: mockReceivedDir, 941 | diffDir: mockDiffDir, 942 | failureThreshold: 0, 943 | failureThresholdType: 'pixel', 944 | runtimeHooksPath: require.resolve('./stubs/runtimeHooksPath.js'), 945 | })).toThrowError( 946 | new Error("Couldn't execute onBeforeWriteToDisc: wrong") 947 | ); 948 | }); 949 | }); 950 | }); 951 | 952 | -------------------------------------------------------------------------------- /__tests__/image-composer.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | /* eslint-disable global-require */ 16 | const ImageComposer = require('../src/image-composer'); 17 | 18 | describe('image-composer', () => { 19 | const imageOneRaw = {}; 20 | const imageOneWidth = 100; 21 | const imageOneHeight = 100; 22 | 23 | const imageTwoRaw = {}; 24 | const imageTwoWidth = 100; 25 | const imageTwoHeight = 100; 26 | 27 | it('it should use default direction horizontal', () => { 28 | const composer = new ImageComposer(); 29 | const params = composer.getParams(); 30 | 31 | expect(params).toHaveProperty('direction', 'horizontal'); 32 | }); 33 | 34 | it('it should change direction to vertical', () => { 35 | const composer = new ImageComposer({ direction: 'vertical' }); 36 | const params = composer.getParams(); 37 | 38 | expect(params).toHaveProperty('direction', 'vertical'); 39 | }); 40 | 41 | it('it should add one image', () => { 42 | const composer = new ImageComposer(); 43 | composer.addImage(imageOneRaw, imageOneWidth, imageOneHeight); 44 | const params = composer.getParams(); 45 | 46 | expect(params).toHaveProperty('imagesCount', 1); 47 | }); 48 | 49 | it('it should add two image', () => { 50 | const composer = new ImageComposer(); 51 | composer.addImage(imageOneRaw, imageOneWidth, imageOneHeight); 52 | composer.addImage(imageTwoRaw, imageTwoWidth, imageTwoHeight); 53 | const params = composer.getParams(); 54 | 55 | expect(params).toHaveProperty('imagesCount', 2); 56 | }); 57 | 58 | it('it should align images horizontally', () => { 59 | const composer = new ImageComposer(); 60 | composer.addImage(imageOneRaw, imageOneWidth, imageOneHeight); 61 | composer.addImage(imageTwoRaw, imageTwoWidth, imageTwoHeight); 62 | const params = composer.getParams(); 63 | 64 | expect(params).toHaveProperty('compositeWidth', 200); 65 | expect(params).toHaveProperty('compositeHeight', 100); 66 | expect(params).toHaveProperty('offsetX', 100); 67 | expect(params).toHaveProperty('offsetY', 0); 68 | }); 69 | 70 | it('it should align images vertically', () => { 71 | const composer = new ImageComposer({ direction: 'vertical' }); 72 | composer.addImage(imageOneRaw, imageOneWidth, imageOneHeight); 73 | composer.addImage(imageTwoRaw, imageTwoWidth, imageTwoHeight); 74 | const params = composer.getParams(); 75 | 76 | expect(params).toHaveProperty('compositeWidth', 100); 77 | expect(params).toHaveProperty('compositeHeight', 200); 78 | expect(params).toHaveProperty('offsetX', 0); 79 | expect(params).toHaveProperty('offsetY', 100); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | /* eslint-disable global-require */ 16 | const fs = require('fs'); 17 | const path = require('path'); 18 | 19 | describe('toMatchImageSnapshot', () => { 20 | function setupMock(diffImageToSnapshotResult, mockSupportsColor = true) { 21 | jest.doMock('../src/diff-snapshot', () => ({ 22 | runDiffImageToSnapshot: jest.fn(() => diffImageToSnapshotResult), 23 | })); 24 | 25 | jest.mock('supports-color', () => ({ 26 | // 1 means basic ANSI 16-color support, 0 means no support 27 | stdout: { level: mockSupportsColor ? 1 : 0 }, 28 | stderr: { level: mockSupportsColor ? 1 : 0 }, 29 | })); 30 | 31 | const mockFs = Object.assign({}, fs, { 32 | existsSync: jest.fn(), 33 | unlinkSync: jest.fn(), 34 | }); 35 | mockFs.existsSync.mockImplementation(p => p === 'test/path'); 36 | jest.mock('fs', () => mockFs); 37 | 38 | return { 39 | mockFs, 40 | }; 41 | } 42 | 43 | beforeEach(() => { 44 | // In tests, skip reporting (skip snapshotState update to not mess with our test report) 45 | global.UNSTABLE_SKIP_REPORTING = true; 46 | jest.resetModules(); 47 | jest.resetAllMocks(); 48 | }); 49 | 50 | afterEach(() => { 51 | jest.unmock('fs'); 52 | jest.unmock('chalk'); 53 | }); 54 | 55 | it('should throw an error if used with .not matcher', () => { 56 | const mockDiffResult = { 57 | pass: true, 58 | diffOutputPath: 'path/to/result.png', 59 | diffRatio: 0, 60 | diffPixelCount: 0, 61 | }; 62 | 63 | setupMock(mockDiffResult); 64 | const { toMatchImageSnapshot } = require('../src/index'); 65 | expect.extend({ toMatchImageSnapshot }); 66 | 67 | expect(() => expect('pretendthisisanimagebuffer').not.toMatchImageSnapshot()) 68 | .toThrowErrorMatchingSnapshot(); 69 | }); 70 | 71 | it('should pass when snapshot is similar enough or same as baseline snapshot', () => { 72 | const mockDiffResult = { 73 | pass: true, 74 | diffOutputPath: 'path/to/result.png', 75 | diffRatio: 0, 76 | diffPixelCount: 0, 77 | }; 78 | setupMock(mockDiffResult); 79 | 80 | const { toMatchImageSnapshot } = require('../src/index'); 81 | expect.extend({ toMatchImageSnapshot }); 82 | 83 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 84 | .not.toThrow(); 85 | }); 86 | 87 | it('should fail when snapshot has a difference beyond allowed threshold', () => { 88 | const mockDiffResult = { 89 | pass: false, 90 | diffOutputPath: 'path/to/result.png', 91 | diffRatio: 0.8, 92 | diffPixelCount: 600, 93 | }; 94 | 95 | setupMock(mockDiffResult); 96 | const { toMatchImageSnapshot } = require('../src/index'); 97 | expect.extend({ toMatchImageSnapshot }); 98 | 99 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 100 | .toThrowErrorMatchingSnapshot(); 101 | }); 102 | 103 | it('should fail when snapshot is a different size than the baseline', () => { 104 | const mockDiffResult = { 105 | pass: false, 106 | diffSize: true, 107 | imageDimensions: { 108 | receivedHeight: 100, 109 | receivedWidth: 100, 110 | baselineHeight: 10, 111 | baselineWidth: 10, 112 | }, 113 | diffOutputPath: 'path/to/result.png', 114 | diffRatio: 0.8, 115 | diffPixelCount: 600, 116 | }; 117 | 118 | setupMock(mockDiffResult); 119 | const { toMatchImageSnapshot } = require('../src/index'); 120 | expect.extend({ toMatchImageSnapshot }); 121 | 122 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 123 | .toThrow(/Expected image to be the same size as the snapshot/); 124 | }); 125 | 126 | it('should use noColors options if passed as true and not style error message', () => { 127 | const mockDiffResult = { 128 | pass: false, 129 | diffOutputPath: 'path/to/result.png', 130 | diffRatio: 0.4, 131 | diffPixelCount: 600, 132 | }; 133 | 134 | setupMock(mockDiffResult); 135 | const { toMatchImageSnapshot } = require('../src/index'); 136 | expect.extend({ toMatchImageSnapshot }); 137 | 138 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ noColors: true })) 139 | .toThrowErrorMatchingSnapshot(); 140 | }); 141 | 142 | it('should use noColors options if passed as false and style error message', () => { 143 | const mockDiffResult = { 144 | pass: false, 145 | diffOutputPath: 'path/to/result.png', 146 | diffRatio: 0.4, 147 | diffPixelCount: 600, 148 | }; 149 | const mockSupportsColor = false; 150 | 151 | setupMock(mockDiffResult, mockSupportsColor); 152 | const { toMatchImageSnapshot } = require('../src/index'); 153 | expect.extend({ toMatchImageSnapshot }); 154 | 155 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ noColors: false })) 156 | .toThrowErrorMatchingSnapshot(); 157 | }); 158 | 159 | it('should not style error message if colors not supported ', () => { 160 | const mockDiffResult = { 161 | pass: false, 162 | diffOutputPath: 'path/to/result.png', 163 | diffRatio: 0.4, 164 | diffPixelCount: 600, 165 | }; 166 | const mockSupportsColor = false; 167 | 168 | setupMock(mockDiffResult, mockSupportsColor); 169 | const { toMatchImageSnapshot } = require('../src/index'); 170 | expect.extend({ toMatchImageSnapshot }); 171 | 172 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 173 | .toThrowErrorMatchingSnapshot(); 174 | }); 175 | 176 | it('should style error message if colors supported ', () => { 177 | const mockDiffResult = { 178 | pass: false, 179 | diffOutputPath: 'path/to/result.png', 180 | diffRatio: 0.4, 181 | diffPixelCount: 600, 182 | }; 183 | 184 | setupMock(mockDiffResult); 185 | const { toMatchImageSnapshot } = require('../src/index'); 186 | expect.extend({ toMatchImageSnapshot }); 187 | 188 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 189 | .toThrowErrorMatchingSnapshot(); 190 | }); 191 | 192 | it('should use custom pixelmatch configuration if passed in', () => { 193 | const mockTestContext = { 194 | testPath: 'path/to/test.spec.js', 195 | currentTestName: 'test1', 196 | isNot: false, 197 | snapshotState: { 198 | _counters: new Map(), 199 | _updateSnapshot: 'new', 200 | updated: undefined, 201 | added: true, 202 | }, 203 | }; 204 | 205 | const mockDiffResult = { 206 | pass: false, 207 | diffOutputPath: 'path/to/result.png', 208 | diffRatio: 0.8, 209 | diffPixelCount: 600, 210 | }; 211 | 212 | setupMock(mockDiffResult); 213 | const { toMatchImageSnapshot } = require('../src/index'); 214 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 215 | 216 | const customDiffConfig = { threshold: 0.3 }; 217 | matcherAtTest('pretendthisisanimagebuffer', { customDiffConfig }); 218 | const { runDiffImageToSnapshot } = require('../src/diff-snapshot'); 219 | expect(runDiffImageToSnapshot.mock.calls[0][0].customDiffConfig).toEqual(customDiffConfig); 220 | }); 221 | 222 | it('passes diffImageToSnapshot everything it needs to create a snapshot and compare if needed', () => { 223 | const mockTestContext = { 224 | testPath: 'path/to/test.spec.js', 225 | currentTestName: 'test', 226 | isNot: false, 227 | snapshotState: { 228 | _counters: new Map(), 229 | _updateSnapshot: 'new', 230 | updated: undefined, 231 | added: true, 232 | }, 233 | }; 234 | 235 | const mockDiffResult = { 236 | pass: false, 237 | diffOutputPath: 'path/to/result.png', 238 | diffRatio: 0.8, 239 | diffPixelCount: 600, 240 | }; 241 | 242 | setupMock(mockDiffResult); 243 | const { toMatchImageSnapshot } = require('../src/index'); 244 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 245 | 246 | matcherAtTest('pretendthisisanimagebuffer'); 247 | const { runDiffImageToSnapshot } = require('../src/diff-snapshot'); 248 | 249 | const dataArg = runDiffImageToSnapshot.mock.calls[0][0]; 250 | // This is to make the test work on windows 251 | dataArg.snapshotsDir = dataArg.snapshotsDir.replace(/\\/g, '/'); 252 | 253 | expect(dataArg).toMatchSnapshot(); 254 | }); 255 | 256 | it('passes uses user passed snapshot name if given', () => { 257 | const mockTestContext = { 258 | testPath: 'path/to/test.spec.js', 259 | currentTestName: 'test', 260 | isNot: false, 261 | snapshotState: { 262 | _counters: new Map(), 263 | _updateSnapshot: 'new', 264 | updated: undefined, 265 | added: true, 266 | }, 267 | }; 268 | 269 | const mockDiffResult = { 270 | pass: false, 271 | diffOutputPath: 'path/to/result.png', 272 | diffRatio: 0.8, 273 | diffPixelCount: 600, 274 | }; 275 | 276 | setupMock(mockDiffResult); 277 | const { toMatchImageSnapshot } = require('../src/index'); 278 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 279 | 280 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 281 | const { runDiffImageToSnapshot } = require('../src/diff-snapshot'); 282 | 283 | expect(runDiffImageToSnapshot.mock.calls[0][0].snapshotIdentifier).toBe('custom-name'); 284 | 285 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: () => 'functional-name' }); 286 | expect(runDiffImageToSnapshot.mock.calls[1][0].snapshotIdentifier).toBe('functional-name'); 287 | 288 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: () => '' }); 289 | expect(runDiffImageToSnapshot.mock.calls[2][0].snapshotIdentifier).toBe('test-spec-js-test-3'); 290 | 291 | const mockCustomSnap = jest.fn(); 292 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: mockCustomSnap }); 293 | 294 | expect(mockCustomSnap).toHaveBeenCalledWith({ 295 | testPath: mockTestContext.testPath, 296 | currentTestName: mockTestContext.currentTestName, 297 | counter: 4, 298 | defaultIdentifier: 'test-spec-js-test-4', 299 | }); 300 | }); 301 | 302 | it('attempts to update snapshots if snapshotState has updateSnapshot flag set', () => { 303 | const mockTestContext = { 304 | testPath: 'path/to/test.spec.js', 305 | currentTestName: 'test1', 306 | isNot: false, 307 | snapshotState: { 308 | _counters: new Map(), 309 | _updateSnapshot: 'all', 310 | updated: undefined, 311 | added: true, 312 | }, 313 | }; 314 | const mockDiffResult = { updated: true }; 315 | 316 | setupMock(mockDiffResult); 317 | const { toMatchImageSnapshot } = require('../src/index'); 318 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 319 | 320 | matcherAtTest('pretendthisisanimagebuffer'); 321 | const { runDiffImageToSnapshot } = require('../src/diff-snapshot'); 322 | 323 | expect(runDiffImageToSnapshot.mock.calls[0][0].updateSnapshot).toBe(true); 324 | }); 325 | 326 | it('should work when a new snapshot is added', () => { 327 | const mockTestContext = { 328 | testPath: 'path/to/test.spec.js', 329 | currentTestName: 'test1', 330 | isNot: false, 331 | snapshotState: { 332 | _counters: new Map(), 333 | update: false, 334 | _updateSnapshot: 'new', 335 | updated: undefined, 336 | added: true, 337 | }, 338 | }; 339 | const mockDiff = jest.fn(); 340 | jest.doMock('../src/diff-snapshot', () => ({ 341 | runDiffImageToSnapshot: mockDiff, 342 | })); 343 | 344 | const mockFs = Object.assign({}, fs, { 345 | existsSync: jest.fn(), 346 | unlinkSync: jest.fn(), 347 | }); 348 | 349 | mockFs.existsSync.mockReturnValueOnce(false); 350 | mockDiff.mockReturnValueOnce({ added: true }); 351 | 352 | const { toMatchImageSnapshot } = require('../src/index'); 353 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 354 | expect(matcherAtTest('pretendthisisanimagebuffer')).toHaveProperty('pass', true); 355 | expect(mockDiff).toHaveBeenCalled(); 356 | }); 357 | 358 | it('should fail when a new snapshot is added in ci', () => { 359 | const mockTestContext = { 360 | testPath: 'path/to/test.spec.js', 361 | currentTestName: 'test1', 362 | isNot: false, 363 | snapshotState: { 364 | _counters: new Map(), 365 | update: false, 366 | _updateSnapshot: 'none', 367 | updated: undefined, 368 | added: true, 369 | }, 370 | }; 371 | 372 | const mockDiff = jest.fn(); 373 | jest.doMock('../src/diff-snapshot', () => ({ 374 | diffImageToSnapshot: mockDiff, 375 | })); 376 | 377 | const mockFs = Object.assign({}, fs, { 378 | existsSync: jest.fn(), 379 | unlinkSync: jest.fn(), 380 | }); 381 | 382 | mockFs.existsSync.mockReturnValueOnce(false); 383 | 384 | 385 | const { toMatchImageSnapshot } = require('../src/index'); 386 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 387 | const result = matcherAtTest('pretendthisisanimagebuffer'); 388 | expect(result).toHaveProperty('pass', false); 389 | expect(result).toHaveProperty('message'); 390 | expect(result.message()).toContain('continuous integration'); 391 | expect(mockDiff).not.toHaveBeenCalled(); 392 | }); 393 | 394 | it('should work when a snapshot is updated', () => { 395 | const mockTestContext = { 396 | testPath: 'path/to/test.spec.js', 397 | currentTestName: 'test1', 398 | isNot: false, 399 | snapshotState: { 400 | _counters: new Map(), 401 | update: true, 402 | updated: undefined, 403 | added: undefined, 404 | }, 405 | }; 406 | const mockDiffResult = { updated: true }; 407 | 408 | setupMock(mockDiffResult); 409 | const { toMatchImageSnapshot } = require('../src/index'); 410 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 411 | expect(() => matcherAtTest('pretendthisisanimagebuffer')).not.toThrow(); 412 | }); 413 | 414 | it('should pass with defaults', () => { 415 | const mockTestContext = { 416 | testPath: path.join('path', 'to', 'test.spec.js'), 417 | currentTestName: 'test1', 418 | isNot: false, 419 | snapshotState: { 420 | _counters: new Map(), 421 | update: true, 422 | updated: undefined, 423 | added: undefined, 424 | }, 425 | }; 426 | setupMock({ updated: true }); 427 | 428 | const runDiffImageToSnapshot = jest.fn(() => ({})); 429 | jest.doMock('../src/diff-snapshot', () => ({ 430 | runDiffImageToSnapshot, 431 | })); 432 | 433 | const Chalk = require('chalk').Instance; 434 | jest.mock('chalk'); 435 | const { toMatchImageSnapshot } = require('../src/index'); 436 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 437 | 438 | matcherAtTest(); 439 | 440 | expect(runDiffImageToSnapshot).toHaveBeenCalledWith({ 441 | allowSizeMismatch: false, 442 | blur: 0, 443 | comparisonMethod: 'pixelmatch', 444 | currentTestName: 'test1', 445 | customDiffConfig: {}, 446 | diffDir: undefined, 447 | diffDirection: 'horizontal', 448 | onlyDiff: false, 449 | failureThreshold: 0, 450 | failureThresholdType: 'pixel', 451 | maxChildProcessBufferSizeInBytes: 10 * 1024 * 1024, 452 | receivedDir: undefined, 453 | receivedImageBuffer: undefined, 454 | runtimeHooksPath: undefined, 455 | snapshotIdentifier: 'test-spec-js-test-1-1-snap', 456 | snapshotsDir: process.platform === 'win32' ? 'path\\to\\__image_snapshots__' : 'path/to/__image_snapshots__', 457 | storeReceivedOnFailure: false, 458 | testPath: path.normalize('path/to/test.spec.js'), 459 | updatePassedSnapshot: false, 460 | updateSnapshot: false, 461 | }); 462 | expect(Chalk).toHaveBeenCalledWith({}); 463 | }); 464 | 465 | it('can provide custom defaults', () => { 466 | const mockTestContext = { 467 | testPath: path.join('path', 'to', 'test.spec.js'), 468 | currentTestName: 'test1', 469 | isNot: false, 470 | snapshotState: { 471 | _counters: new Map(), 472 | update: true, 473 | updated: undefined, 474 | added: undefined, 475 | }, 476 | }; 477 | setupMock({ updated: true }); 478 | 479 | const runDiffImageToSnapshot = jest.fn(() => ({})); 480 | jest.doMock('../src/diff-snapshot', () => ({ 481 | runDiffImageToSnapshot, 482 | })); 483 | 484 | const Chalk = require('chalk').Instance; 485 | jest.mock('chalk'); 486 | const { configureToMatchImageSnapshot } = require('../src/index'); 487 | const customDiffConfig = { perceptual: true }; 488 | const customSnapshotIdentifier = ({ defaultIdentifier }) => 489 | `custom-${defaultIdentifier}`; 490 | const comparisonMethod = 'ssim'; 491 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ 492 | customDiffConfig, 493 | customSnapshotIdentifier, 494 | customSnapshotsDir: path.join('path', 'to', 'my-custom-snapshots-dir'), 495 | customReceivedDir: path.join('path', 'to', 'my-custom-received-dir'), 496 | storeReceivedOnFailure: true, 497 | customDiffDir: path.join('path', 'to', 'my-custom-diff-dir'), 498 | diffDirection: 'vertical', 499 | noColors: true, 500 | failureThreshold: 1, 501 | failureThresholdType: 'percent', 502 | updatePassedSnapshot: true, 503 | blur: 1, 504 | maxChildProcessBufferSizeInBytes: 1024 * 1024, 505 | comparisonMethod, 506 | }); 507 | expect.extend({ toMatchImageSnapshot }); 508 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 509 | 510 | matcherAtTest(); 511 | 512 | expect(runDiffImageToSnapshot).toHaveBeenCalledWith({ 513 | allowSizeMismatch: false, 514 | blur: 1, 515 | customDiffConfig: { 516 | perceptual: true, 517 | }, 518 | currentTestName: 'test1', 519 | snapshotIdentifier: 'custom-test-spec-js-test-1-1', 520 | snapshotsDir: path.join('path', 'to', 'my-custom-snapshots-dir'), 521 | receivedDir: path.join('path', 'to', 'my-custom-received-dir'), 522 | receivedImageBuffer: undefined, 523 | runtimeHooksPath: undefined, 524 | storeReceivedOnFailure: true, 525 | diffDir: path.join('path', 'to', 'my-custom-diff-dir'), 526 | diffDirection: 'vertical', 527 | onlyDiff: false, 528 | testPath: path.join('path', 'to', 'test.spec.js'), 529 | updateSnapshot: false, 530 | updatePassedSnapshot: true, 531 | failureThreshold: 1, 532 | failureThresholdType: 'percent', 533 | maxChildProcessBufferSizeInBytes: 1024 * 1024, 534 | comparisonMethod, 535 | }); 536 | expect(Chalk).toHaveBeenCalledWith({ 537 | level: 0, // noColors 538 | }); 539 | }); 540 | 541 | it('can run in process', () => { 542 | const mockTestContext = { 543 | testPath: path.join('path', 'to', 'test.spec.js'), 544 | currentTestName: 'test1', 545 | isNot: false, 546 | snapshotState: { 547 | _counters: new Map(), 548 | update: true, 549 | updated: undefined, 550 | added: undefined, 551 | }, 552 | }; 553 | setupMock({ updated: true }); 554 | 555 | const diffImageToSnapshot = jest.fn(() => ({})); 556 | jest.doMock('../src/diff-snapshot', () => ({ 557 | diffImageToSnapshot, 558 | })); 559 | 560 | const Chalk = require('chalk').Instance; 561 | jest.mock('chalk'); 562 | const { configureToMatchImageSnapshot } = require('../src/index'); 563 | const customConfig = { perceptual: true }; 564 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ 565 | customDiffConfig: customConfig, 566 | customSnapshotsDir: path.join('path', 'to', 'my-custom-snapshots-dir'), 567 | customReceivedDir: path.join('path', 'to', 'my-custom-received-dir'), 568 | customDiffDir: path.join('path', 'to', 'my-custom-diff-dir'), 569 | storeReceivedOnFailure: true, 570 | noColors: true, 571 | runInProcess: true, 572 | }); 573 | expect.extend({ toMatchImageSnapshot }); 574 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 575 | 576 | matcherAtTest(); 577 | 578 | expect(diffImageToSnapshot).toHaveBeenCalledWith({ 579 | allowSizeMismatch: false, 580 | blur: 0, 581 | customDiffConfig: { 582 | perceptual: true, 583 | }, 584 | snapshotIdentifier: 'test-spec-js-test-1-1-snap', 585 | snapshotsDir: path.join('path', 'to', 'my-custom-snapshots-dir'), 586 | receivedImageBuffer: undefined, 587 | runtimeHooksPath: undefined, 588 | receivedDir: path.join('path', 'to', 'my-custom-received-dir'), 589 | diffDir: path.join('path', 'to', 'my-custom-diff-dir'), 590 | diffDirection: 'horizontal', 591 | onlyDiff: false, 592 | storeReceivedOnFailure: true, 593 | testPath: path.join('path', 'to', 'test.spec.js'), 594 | updateSnapshot: false, 595 | updatePassedSnapshot: false, 596 | maxChildProcessBufferSizeInBytes: 10 * 1024 * 1024, 597 | failureThreshold: 0, 598 | failureThresholdType: 'pixel', 599 | comparisonMethod: 'pixelmatch', 600 | currentTestName: 'test1', 601 | }); 602 | expect(Chalk).toHaveBeenCalledWith({ 603 | level: 0, // noColors 604 | }); 605 | }); 606 | 607 | it('should only increment matched when test passed', () => { 608 | global.UNSTABLE_SKIP_REPORTING = false; 609 | 610 | const mockTestContext = { 611 | testPath: 'path/to/test.spec.js', 612 | currentTestName: 'test', 613 | isNot: false, 614 | snapshotState: { 615 | _counters: new Map(), 616 | _updateSnapshot: 'new', 617 | updated: undefined, 618 | added: true, 619 | unmatched: 0, 620 | matched: 0, 621 | }, 622 | }; 623 | 624 | const mockDiffResult = { 625 | pass: true, 626 | diffOutputPath: 'path/to/result.png', 627 | diffRatio: 0, 628 | diffPixelCount: 0, 629 | }; 630 | 631 | setupMock(mockDiffResult); 632 | const { toMatchImageSnapshot } = require('../src/index'); 633 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 634 | 635 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 636 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 637 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 638 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 639 | expect(mockTestContext.snapshotState.matched).toBe(4); 640 | }); 641 | 642 | describe('when retryTimes is set', () => { 643 | beforeEach(() => { global[Symbol.for('RETRY_TIMES')] = 3; }); 644 | afterEach(() => { global[Symbol.for('RETRY_TIMES')] = undefined; }); 645 | 646 | it('should throw an error when called without customSnapshotIdentifier', () => { 647 | const mockDiffResult = { 648 | pass: true, 649 | diffOutputPath: 'path/to/result.png', 650 | diffRatio: 0, 651 | diffPixelCount: 0, 652 | }; 653 | 654 | setupMock(mockDiffResult); 655 | const { toMatchImageSnapshot } = require('../src/index'); 656 | expect.extend({ toMatchImageSnapshot }); 657 | 658 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 659 | .toThrowErrorMatchingSnapshot(); 660 | 661 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ customSnapshotIdentifier: () => '' })) 662 | .toThrowErrorMatchingSnapshot(); 663 | }); 664 | 665 | it('should only increment unmatched when test fails in excess of retryTimes', () => { 666 | global.UNSTABLE_SKIP_REPORTING = false; 667 | 668 | const mockTestContext = { 669 | testPath: 'path/to/test.spec.js', 670 | currentTestName: 'test', 671 | isNot: false, 672 | snapshotState: { 673 | _counters: new Map(), 674 | _updateSnapshot: 'new', 675 | updated: undefined, 676 | added: true, 677 | unmatched: 0, 678 | }, 679 | }; 680 | 681 | const mockDiffResult = { 682 | pass: false, 683 | diffOutputPath: 'path/to/result.png', 684 | diffRatio: 0.8, 685 | diffPixelCount: 600, 686 | }; 687 | 688 | setupMock(mockDiffResult); 689 | const { toMatchImageSnapshot } = require('../src/index'); 690 | const matcherAtTest = toMatchImageSnapshot.bind(mockTestContext); 691 | 692 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 693 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 694 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 695 | matcherAtTest('pretendthisisanimagebuffer', { customSnapshotIdentifier: 'custom-name' }); 696 | expect(mockTestContext.snapshotState.unmatched).toBe(1); 697 | }); 698 | }); 699 | describe('dumpDiffToConsole', () => { 700 | it('imgSrcString is added to console message when dumpDiffToConsole is true', () => { 701 | const mockDiffResult = { 702 | pass: false, 703 | diffOutputPath: 'path/to/result.png', 704 | diffRatio: 0.8, 705 | diffPixelCount: 600, 706 | imgSrcString: 'pretendthisisanimagebase64string', 707 | }; 708 | 709 | setupMock(mockDiffResult); 710 | const { toMatchImageSnapshot } = require('../src/index'); 711 | expect.extend({ toMatchImageSnapshot }); 712 | 713 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ dumpDiffToConsole: true })) 714 | .toThrowErrorMatchingSnapshot(); 715 | }); 716 | 717 | it('imgSrcString is not added to console by default', () => { 718 | const mockDiffResult = { 719 | pass: false, 720 | diffOutputPath: 'path/to/result.png', 721 | diffRatio: 0, 722 | diffPixelCount: 0, 723 | imgSrcString: 'pretendthisisanimagebase64string', 724 | }; 725 | 726 | setupMock(mockDiffResult); 727 | const { toMatchImageSnapshot } = require('../src/index'); 728 | expect.extend({ toMatchImageSnapshot }); 729 | 730 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot()) 731 | .toThrowErrorMatchingSnapshot(); 732 | }); 733 | }); 734 | 735 | describe('dumpInlineDiffToConsole', () => { 736 | const { TERM_PROGRAM } = process.env; 737 | 738 | afterEach(() => { process.env.TERM_PROGRAM = TERM_PROGRAM; }); 739 | 740 | it('falls back to dumpDiffToConsole if the terminal is unsupported', () => { 741 | const mockDiffResult = { 742 | pass: false, 743 | diffOutputPath: 'path/to/result.png', 744 | diffRatio: 0.8, 745 | diffPixelCount: 600, 746 | imgSrcString: 'pretendthisisanimagebase64string', 747 | }; 748 | setupMock(mockDiffResult); 749 | const { toMatchImageSnapshot } = require('../src/index'); 750 | expect.extend({ toMatchImageSnapshot }); 751 | 752 | process.env.TERM_PROGRAM = 'xterm'; 753 | 754 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ dumpInlineDiffToConsole: true })) 755 | .toThrowErrorMatchingSnapshot(); 756 | }); 757 | 758 | it('uses Inline Image Protocol in iTerm', () => { 759 | const mockDiffResult = { 760 | pass: false, 761 | diffOutputPath: 'path/to/result.png', 762 | diffRatio: 0.8, 763 | diffPixelCount: 600, 764 | imgSrcString: 'pretendthisisanimagebase64string', 765 | imageDimensions: { 766 | receivedHeight: 100, 767 | receivedWidth: 200, 768 | }, 769 | }; 770 | setupMock(mockDiffResult); 771 | const { toMatchImageSnapshot } = require('../src/index'); 772 | expect.extend({ toMatchImageSnapshot }); 773 | 774 | process.env.TERM_PROGRAM = 'iTerm.app'; 775 | 776 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ dumpInlineDiffToConsole: true })) 777 | .toThrowErrorMatchingSnapshot(); 778 | }); 779 | 780 | it('uses Inline Image Protocol when ENABLE_INLINE_DIFF is set', () => { 781 | const mockDiffResult = { 782 | pass: false, 783 | diffOutputPath: 'path/to/result.png', 784 | diffRatio: 0.8, 785 | diffPixelCount: 600, 786 | imgSrcString: 'pretendthisisanimagebase64string', 787 | imageDimensions: { 788 | receivedHeight: 100, 789 | receivedWidth: 200, 790 | }, 791 | }; 792 | setupMock(mockDiffResult); 793 | const { toMatchImageSnapshot } = require('../src/index'); 794 | expect.extend({ toMatchImageSnapshot }); 795 | 796 | process.env.ENABLE_INLINE_DIFF = true; 797 | 798 | expect(() => expect('pretendthisisanimagebuffer').toMatchImageSnapshot({ dumpInlineDiffToConsole: true })) 799 | .toThrowErrorMatchingSnapshot(); 800 | }); 801 | }); 802 | }); 803 | 804 | describe('updateSnapshotState', () => { 805 | it('mutates original state', () => { 806 | const { updateSnapshotState } = require('../src/index'); 807 | global.UNSTABLE_SKIP_REPORTING = false; 808 | const originalState = { some: 'value' }; 809 | updateSnapshotState(originalState, { another: 'val' }); 810 | 811 | expect(originalState).toEqual({ some: 'value', another: 'val' }); 812 | }); 813 | }); 814 | -------------------------------------------------------------------------------- /__tests__/integration.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const fs = require('fs'); 16 | const path = require('path'); 17 | const { rimrafSync } = require('rimraf'); 18 | const uniqueId = require('lodash/uniqueId'); 19 | const sizeOf = require('image-size'); 20 | const { SnapshotState } = require('jest-snapshot'); 21 | const { toMatchImageSnapshot } = require('../src'); 22 | 23 | describe('toMatchImageSnapshot', () => { 24 | const fromStubs = file => path.resolve(__dirname, './stubs', file); 25 | const imageData = fs.readFileSync(fromStubs('TestImage.png')); 26 | const diffOutputDir = (snapshotsDir = '__image_snapshots__') => path.join(snapshotsDir, '/__diff_output__/'); 27 | const customSnapshotsDir = path.resolve(__dirname, '__custom_snapshots_dir__'); 28 | const cleanupRequiredIndicator = 'cleanup-required-'; 29 | const getIdentifierIndicatingCleanupIsRequired = () => uniqueId(cleanupRequiredIndicator); 30 | const getSnapshotFilename = identifier => `${identifier}.png`; 31 | const diffExists = identifier => fs.existsSync(path.join(__dirname, diffOutputDir(), `${identifier}-diff.png`)); 32 | 33 | beforeAll(() => { 34 | // In tests, skip reporting (skip snapshotState update to not mess with our test report) 35 | global.UNSTABLE_SKIP_REPORTING = true; 36 | expect.extend({ toMatchImageSnapshot }); 37 | }); 38 | 39 | beforeEach(() => { 40 | rimrafSync(`**/${cleanupRequiredIndicator}*`, { glob: true }); 41 | }); 42 | 43 | afterAll(() => { 44 | rimrafSync(`**/${cleanupRequiredIndicator}*`, { glob: true }); 45 | }); 46 | 47 | describe('happy path', () => { 48 | it('writes snapshot with no error if there is not one stored already', () => { 49 | const snapshotsDir = path.resolve(__dirname, '__image_snapshots__'); 50 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 51 | 52 | expect( 53 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 54 | ).not.toThrowError(); 55 | expect( 56 | fs.existsSync(path.join(snapshotsDir, getSnapshotFilename(customSnapshotIdentifier))) 57 | ).toBe(true); 58 | }); 59 | 60 | it('matches an identical snapshot', () => { 61 | expect(() => expect(imageData).toMatchImageSnapshot()).not.toThrowError(); 62 | }); 63 | 64 | it('creates a snapshot in a custom directory if such is specified', () => { 65 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 66 | 67 | // First we need to write a new snapshot image 68 | expect( 69 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier, customSnapshotsDir }) // eslint-disable-line max-len 70 | ).not.toThrowError(); 71 | 72 | expect( 73 | fs.existsSync(path.join(customSnapshotsDir, getSnapshotFilename(customSnapshotIdentifier))) 74 | ).toBe(true); 75 | }); 76 | 77 | it('does not write a result image for passing tests', () => { 78 | const customSnapshotIdentifier = 'integration-6'; 79 | 80 | // First we need to write a new snapshot image 81 | expect( 82 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 83 | ).not.toThrowError(); 84 | 85 | expect(diffExists(customSnapshotIdentifier)).toBe(false); 86 | }); 87 | 88 | it('does not write a result image for passing tests (ssim)', () => { 89 | const customSnapshotIdentifier = 'integration-6'; 90 | 91 | // First we need to write a new snapshot image 92 | expect( 93 | () => expect(imageData).toMatchImageSnapshot({ 94 | customSnapshotIdentifier, 95 | comparisonMethod: 'ssim', 96 | }) 97 | ).not.toThrowError(); 98 | 99 | expect(diffExists(customSnapshotIdentifier)).toBe(false); 100 | }); 101 | 102 | it('should work with TypedArray', () => { 103 | const imageTypedArray = new Uint8Array(imageData.buffer); 104 | expect(() => expect(imageTypedArray).toMatchImageSnapshot()).not.toThrowError(); 105 | }); 106 | 107 | it('should work with base64 encoded strings', () => { 108 | const imageString = imageData.toString('base64'); 109 | expect(() => expect(imageString).toMatchImageSnapshot()).not.toThrowError(); 110 | }); 111 | }); 112 | 113 | describe('updates', () => { 114 | const customSnapshotIdentifier = 'integration-update'; 115 | const updateImageData = fs.readFileSync(fromStubs('TestImageUpdate1pxOff.png')); 116 | const updateImageSnapshotPath = path.join(__dirname, '__image_snapshots__', `${customSnapshotIdentifier}.png`); 117 | 118 | beforeEach(() => { 119 | fs.writeFileSync(updateImageSnapshotPath, imageData); 120 | }); 121 | 122 | afterAll(() => { 123 | fs.writeFileSync(updateImageSnapshotPath, imageData); 124 | }); 125 | 126 | it('does not write a result image for passing tests in update mode by default', () => { 127 | const updateModeMatcher = toMatchImageSnapshot.bind({ 128 | snapshotState: new SnapshotState(__filename, { 129 | updateSnapshot: 'all', 130 | }), 131 | testPath: __filename, 132 | }); 133 | updateModeMatcher(updateImageData, { 134 | customSnapshotIdentifier, 135 | failureThreshold: 2, 136 | failureThresholdType: 'pixel', 137 | }); 138 | expect(fs.readFileSync(updateImageSnapshotPath)).toEqual(imageData); 139 | }); 140 | 141 | it('writes a result image for passing test in update mode with updatePassedSnapshots: true', () => { 142 | const updateModeMatcher = toMatchImageSnapshot.bind({ 143 | snapshotState: new SnapshotState(__filename, { 144 | updateSnapshot: 'all', 145 | }), 146 | testPath: __filename, 147 | }); 148 | updateModeMatcher(updateImageData, { 149 | customSnapshotIdentifier, 150 | updatePassedSnapshots: true, 151 | failureThreshold: 2, 152 | failureThresholdType: 'pixel', 153 | }); 154 | expect(fs.readFileSync(updateImageSnapshotPath)).not.toEqual(updateImageData); 155 | }); 156 | 157 | it('writes a result image for passing test in update mode with updatePassedSnapshots: true (ssim)', () => { 158 | const updateModeMatcher = toMatchImageSnapshot.bind({ 159 | snapshotState: new SnapshotState(__filename, { 160 | updateSnapshot: 'all', 161 | }), 162 | testPath: __filename, 163 | }); 164 | updateModeMatcher(updateImageData, { 165 | customSnapshotIdentifier, 166 | updatePassedSnapshots: true, 167 | failureThreshold: 2, 168 | failureThresholdType: 'pixel', 169 | comparisonMode: 'ssim', 170 | }); 171 | expect(fs.readFileSync(updateImageSnapshotPath)).not.toEqual(updateImageData); 172 | }); 173 | 174 | it('writes a result image for failing test in update mode by default', () => { 175 | const updateModeMatcher = toMatchImageSnapshot.bind({ 176 | snapshotState: new SnapshotState(__filename, { 177 | updateSnapshot: 'all', 178 | }), 179 | testPath: __filename, 180 | }); 181 | updateModeMatcher(updateImageData, { 182 | customSnapshotIdentifier, 183 | failureThreshold: 0, 184 | failureThresholdType: 'pixel', 185 | }); 186 | expect(fs.readFileSync(updateImageSnapshotPath)).toEqual(updateImageData); 187 | }); 188 | 189 | it('writes a result image for failing test in update mode with updatePassedSnapshots: false', () => { 190 | const updateModeMatcher = toMatchImageSnapshot.bind({ 191 | snapshotState: new SnapshotState(__filename, { 192 | updateSnapshot: 'all', 193 | }), 194 | testPath: __filename, 195 | }); 196 | updateModeMatcher(updateImageData, { 197 | customSnapshotIdentifier, 198 | updatePassedSnapshots: true, 199 | failureThreshold: 0, 200 | failureThresholdType: 'pixel', 201 | }); 202 | expect(fs.readFileSync(updateImageSnapshotPath)).toEqual(updateImageData); 203 | }); 204 | 205 | it('writes a result image for failing test in update mode with updatePassedSnapshots: false (ssim)', () => { 206 | const updateModeMatcher = toMatchImageSnapshot.bind({ 207 | snapshotState: new SnapshotState(__filename, { 208 | updateSnapshot: 'all', 209 | }), 210 | testPath: __filename, 211 | }); 212 | updateModeMatcher(updateImageData, { 213 | customSnapshotIdentifier, 214 | updatePassedSnapshots: false, 215 | failureThreshold: 0, 216 | failureThresholdType: 'pixel', 217 | comparisonMode: 'ssim', 218 | }); 219 | expect(fs.readFileSync(updateImageSnapshotPath)).toEqual(updateImageData); 220 | }); 221 | }); 222 | 223 | describe('failures', () => { 224 | const failImageData = fs.readFileSync(fromStubs('TestImageFailure.png')); 225 | const oversizeImageData = fs.readFileSync(fromStubs('TestImageFailureOversize.png')); 226 | const biggerImageData = fs.readFileSync(fromStubs('TestImage150x150.png')); 227 | 228 | it('fails for a different snapshot', () => { 229 | const expectedError = /^Expected image to match or be a close match to snapshot but was 86\.45% different from snapshot \(8645 differing pixels\)\./; 230 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 231 | 232 | // Write a new snapshot image 233 | expect( 234 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 235 | ).not.toThrowError(); 236 | 237 | // Test against a different image 238 | expect( 239 | () => expect(failImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 240 | ).toThrowError(expectedError); 241 | }); 242 | 243 | it('fails with differently sized images and outputs diff', () => { 244 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 245 | 246 | // First we need to write a new snapshot image 247 | expect( 248 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 249 | ).not.toThrowError(); 250 | 251 | // Test against an image much larger than the snapshot. 252 | expect( 253 | () => expect(oversizeImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 254 | ).toThrowError(/Expected image to be the same size as the snapshot \(100x100\), but was different \(153x145\)/); 255 | 256 | expect(diffExists(customSnapshotIdentifier)) 257 | .toBe(true); 258 | }); 259 | 260 | it('fails with images without diff pixels after being resized', () => { 261 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 262 | 263 | expect( 264 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 265 | ).not.toThrowError(); 266 | 267 | expect( 268 | () => expect(biggerImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 269 | ).toThrowError(/Expected image to be the same size as the snapshot \(100x100\), but was different \(150x150\)/); 270 | 271 | expect(diffExists(customSnapshotIdentifier)).toBe(true); 272 | }); 273 | 274 | it('writes a result image for failing tests', () => { 275 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 276 | const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); 277 | // First we need to write a new snapshot image 278 | expect( 279 | () => expect(imageData) 280 | .toMatchImageSnapshot({ customSnapshotIdentifier }) 281 | ).not.toThrowError(); 282 | 283 | // then test against a different image 284 | expect( 285 | () => expect(failImageData) 286 | .toMatchImageSnapshot({ customSnapshotIdentifier }) 287 | ).toThrow(); 288 | 289 | expect(fs.existsSync(pathToResultImage)) 290 | .toBe(true); 291 | 292 | // just because file was written does not mean it is a png image 293 | expect(sizeOf(pathToResultImage)).toHaveProperty('type', 'png'); 294 | }); 295 | 296 | it('writes a result image for failing tests (ssim)', () => { 297 | const largeImageData = fs.readFileSync(fromStubs('LargeTestImage.png')); 298 | const largeFailureImageData = fs.readFileSync(fromStubs('LargeTestImageFailure.png')); 299 | const largeImageFailureDiffData = 300 | fs.readFileSync(fromStubs('LargeTestImage-LargeTestImageFailure-ssim-diff.png')); 301 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 302 | const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); 303 | // First we need to write a new snapshot image 304 | expect( 305 | () => expect(largeImageData) 306 | .toMatchImageSnapshot({ 307 | customSnapshotIdentifier, comparisonMethod: 'ssim', 308 | }) 309 | ) 310 | .not 311 | .toThrowError(); 312 | 313 | // then test against a different image 314 | expect( 315 | () => expect(largeFailureImageData) 316 | .toMatchImageSnapshot({ 317 | customSnapshotIdentifier, comparisonMethod: 'ssim', 318 | }) 319 | ) 320 | .toThrow(); 321 | 322 | expect(fs.existsSync(pathToResultImage)) 323 | .toBe(true); 324 | 325 | expect(fs.readFileSync(pathToResultImage)).toMatchImageSnapshot(largeImageFailureDiffData); 326 | // just because file was written does not mean it is a png image 327 | expect(sizeOf(pathToResultImage)) 328 | .toHaveProperty('type', 'png'); 329 | }); 330 | 331 | it('writes a result image for failing tests with horizontal layout', () => { 332 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 333 | const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); 334 | // First we need to write a new snapshot image 335 | expect( 336 | () => expect(imageData) 337 | .toMatchImageSnapshot({ 338 | customSnapshotIdentifier, 339 | diffDirection: 'horizontal', 340 | }) 341 | ) 342 | .not 343 | .toThrowError(); 344 | 345 | // then test against a different image 346 | expect( 347 | () => expect(failImageData) 348 | .toMatchImageSnapshot({ 349 | customSnapshotIdentifier, 350 | diffDirection: 'horizontal', 351 | }) 352 | ) 353 | .toThrow(); 354 | 355 | expect(fs.existsSync(pathToResultImage)) 356 | .toBe(true); 357 | 358 | expect(sizeOf(pathToResultImage)) 359 | .toMatchObject({ 360 | width: 300, 361 | height: 100, 362 | type: 'png', 363 | }); 364 | }); 365 | 366 | it('writes a result image for failing tests with vertical layout', () => { 367 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 368 | const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); 369 | // First we need to write a new snapshot image 370 | expect( 371 | () => expect(imageData) 372 | .toMatchImageSnapshot({ 373 | customSnapshotIdentifier, 374 | diffDirection: 'vertical', 375 | }) 376 | ) 377 | .not 378 | .toThrowError(); 379 | 380 | // then test against a different image 381 | expect( 382 | () => expect(failImageData) 383 | .toMatchImageSnapshot({ 384 | customSnapshotIdentifier, 385 | diffDirection: 'vertical', 386 | }) 387 | ) 388 | .toThrow(); 389 | 390 | expect(fs.existsSync(pathToResultImage)) 391 | .toBe(true); 392 | 393 | expect(sizeOf(pathToResultImage)) 394 | .toMatchObject({ 395 | width: 100, 396 | height: 300, 397 | type: 'png', 398 | }); 399 | }); 400 | 401 | it('removes result image from previous test runs for the same snapshot', () => { 402 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 403 | // First we need to write a new snapshot image 404 | expect( 405 | () => expect(imageData) 406 | .toMatchImageSnapshot({ customSnapshotIdentifier }) 407 | ).not.toThrowError(); 408 | 409 | // then test against a different image (to generate a results image) 410 | expect( 411 | () => expect(failImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 412 | ).toThrow(); 413 | 414 | // then test against image that should not generate results image (as it is passing test) 415 | expect( 416 | () => expect(imageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 417 | ).not.toThrowError(); 418 | 419 | expect(diffExists(customSnapshotIdentifier)) 420 | .toBe(false); 421 | }); 422 | 423 | it('only outputs the diff when onlyDiff is enabled', () => { 424 | const failureImageData = fs.readFileSync(fromStubs('TestImageUpdate1pxOff.png')); 425 | const imageFailureOnlyDiffData = 426 | fs.readFileSync(fromStubs('TestImageUpdate1pxOff-onlyDiff-diff.png')); 427 | 428 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 429 | const pathToResultImage = path.join(__dirname, diffOutputDir(), `${customSnapshotIdentifier}-diff.png`); 430 | // First we need to write a new snapshot image 431 | expect( 432 | () => expect(imageData) 433 | .toMatchImageSnapshot({ 434 | customSnapshotIdentifier, 435 | onlyDiff: true, 436 | }) 437 | ) 438 | .not 439 | .toThrowError(); 440 | 441 | // then test against a different image 442 | expect( 443 | () => expect(failureImageData) 444 | .toMatchImageSnapshot({ 445 | customSnapshotIdentifier, 446 | onlyDiff: true, 447 | // required for coverage 448 | runInProcess: true, 449 | }) 450 | ) 451 | .toThrow(/Expected image to match or be a close match/); 452 | 453 | expect(fs.existsSync(pathToResultImage)) 454 | .toBe(true); 455 | 456 | expect(fs.readFileSync(pathToResultImage)).toEqual(imageFailureOnlyDiffData); 457 | // just because file was written does not mean it is a png image 458 | expect(sizeOf(pathToResultImage)) 459 | .toHaveProperty('type', 'png'); 460 | }); 461 | 462 | it('handles diffs for large images', () => { 463 | const largeImageData = fs.readFileSync(fromStubs('LargeTestImage.png')); 464 | const largeFailureImageData = fs.readFileSync(fromStubs('LargeTestImageFailure.png')); 465 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 466 | // First we need to write a new snapshot image 467 | expect( 468 | () => expect(largeImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 469 | ).not.toThrowError(); 470 | 471 | // then test against a different image 472 | expect( 473 | () => expect(largeFailureImageData).toMatchImageSnapshot({ customSnapshotIdentifier }) 474 | ).toThrow(/Expected image to match or be a close match/); 475 | }); 476 | 477 | describe('Desktop Images Test', () => { 478 | it('not to throw at 6pct with pixelmatch with', () => { 479 | const largeImageData = fs.readFileSync(fromStubs('Desktop 1_082.png')); 480 | const largeFailureImageData = fs.readFileSync(fromStubs('Desktop 1_083.png')); 481 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 482 | // First we need to write a new snapshot image 483 | expect( 484 | () => expect(largeImageData) 485 | .toMatchImageSnapshot({ 486 | failureThreshold: 0.06, 487 | failureThresholdType: 'percent', 488 | customSnapshotIdentifier, 489 | }) 490 | ) 491 | .not 492 | .toThrowError(); 493 | 494 | // then test against a different image 495 | expect( 496 | () => expect(largeFailureImageData) 497 | .toMatchImageSnapshot({ 498 | failureThreshold: 0.06, 499 | failureThresholdType: 'percent', 500 | customSnapshotIdentifier, 501 | }) 502 | ) 503 | .not 504 | .toThrowError(); 505 | }); 506 | it('to throw at 1pct with SSIM', () => { 507 | const largeImageData = fs.readFileSync(fromStubs('Desktop 1_082.png')); 508 | const largeFailureImageData = fs.readFileSync(fromStubs('Desktop 1_083.png')); 509 | const customSnapshotIdentifier = getIdentifierIndicatingCleanupIsRequired(); 510 | // First we need to write a new snapshot image 511 | expect( 512 | () => expect(largeImageData) 513 | .toMatchImageSnapshot({ 514 | comparisonMethod: 'ssim', 515 | failureThreshold: 0.01, 516 | failureThresholdType: 'percent', 517 | customSnapshotIdentifier, 518 | }) 519 | ) 520 | .not 521 | .toThrowError(); 522 | 523 | // then test against a different image 524 | expect( 525 | () => expect(largeFailureImageData) 526 | .toMatchImageSnapshot({ 527 | comparisonMethod: 'ssim', 528 | failureThreshold: 0.01, 529 | failureThresholdType: 'percent', 530 | customSnapshotIdentifier, 531 | // required for coverage 532 | runInProcess: true, 533 | }) 534 | ) 535 | .toThrow(/Expected image to match or be a close match/); 536 | }); 537 | }); 538 | }); 539 | }); 540 | -------------------------------------------------------------------------------- /__tests__/outdated-snapshot-reporter.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const fs = require('fs'); 16 | const os = require('os'); 17 | const childProcess = require('child_process'); 18 | const path = require('path'); 19 | 20 | describe('OutdatedSnapshotReporter', () => { 21 | const jestImageSnapshotDir = path.join(__dirname, '..'); 22 | const imagePath = path.join(__dirname, 'stubs/TestImage.png'); 23 | const jestExe = process.platform === 'win32' ? 'jest.cmd' : 'jest'; 24 | const jestBinPath = path.join(jestImageSnapshotDir, `node_modules/.bin/${jestExe}`); 25 | let tmpDir = os.tmpdir(); 26 | 27 | function setupTestProject(dir) { 28 | const jestConfig = { 29 | reporters: [ 30 | 'default', 31 | `${jestImageSnapshotDir}/src/outdated-snapshot-reporter.js`, 32 | ], 33 | }; 34 | const jestConfigFile = `module.exports = ${JSON.stringify(jestConfig)}`; 35 | 36 | const commonTest = ` 37 | const fs = require('fs'); 38 | const {toMatchImageSnapshot} = require('${jestImageSnapshotDir.replace(/\\/g, '/')}'); 39 | expect.extend({toMatchImageSnapshot}); 40 | `; 41 | const imageTest = `${commonTest} 42 | it('should run an image snapshot test', () => { 43 | expect(fs.readFileSync('image.png')).toMatchImageSnapshot(); 44 | }); 45 | `; 46 | const doubleTest = `${imageTest} 47 | it('should run an image snapshot test', () => { 48 | expect(fs.readFileSync('image.png')).toMatchImageSnapshot(); 49 | }); 50 | `; 51 | 52 | fs.writeFileSync(path.join(dir, 'jest.config.js'), jestConfigFile); 53 | fs.writeFileSync(path.join(dir, 'image.test.js'), imageTest); 54 | fs.writeFileSync(path.join(dir, 'double.test.js'), doubleTest); 55 | fs.copyFileSync(imagePath, path.join(dir, 'image.png')); 56 | } 57 | 58 | function runJest(cliArgs, environment = {}) { 59 | const child = childProcess.spawnSync(jestBinPath, cliArgs, { 60 | cwd: tmpDir, 61 | encoding: 'utf-8', 62 | env: { ...process.env, ...environment }, 63 | shell: true, 64 | }); 65 | if (child.error) throw child.error; 66 | 67 | return child; 68 | } 69 | 70 | function getSnapshotFiles() { 71 | return fs.readdirSync(path.join(tmpDir, '__image_snapshots__')); 72 | } 73 | 74 | beforeAll(() => { 75 | tmpDir = fs.mkdtempSync( 76 | path.join(os.tmpdir(), 'jest-image-snapshot-tests') 77 | ); 78 | setupTestProject(tmpDir); 79 | }); 80 | 81 | afterAll(() => { 82 | fs.rmSync(tmpDir, { recursive: true, force: true }); 83 | }); 84 | 85 | it('should write the image snapshot on first run', () => { 86 | const { status, stdout, stderr } = runJest(['-u']); 87 | expect(stderr).toContain('snapshots written'); 88 | expect(status).toEqual(0); 89 | expect(stdout).toEqual(''); 90 | 91 | expect(getSnapshotFiles()).toHaveLength(3); 92 | }); 93 | 94 | it('should not delete the snapshot when environment flag is not enabled', () => { 95 | const { status, stdout } = runJest(['-u', 'image.test.js']); 96 | expect(status).toEqual(0); 97 | expect(stdout).toEqual(''); 98 | 99 | expect(getSnapshotFiles()).toHaveLength(3); 100 | }); 101 | 102 | it('should delete the snapshot when environment flag is enabled', () => { 103 | const { status, stdout, stderr } = runJest(['-u', 'image.test.js'], { 104 | JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE: '1', 105 | }); 106 | expect(stderr).toContain('outdated snapshot'); 107 | expect(status).toEqual(0); 108 | expect(stdout).toEqual(''); 109 | 110 | expect(getSnapshotFiles()).toHaveLength(1); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /__tests__/stubs/Desktop 1_082.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/Desktop 1_082.png -------------------------------------------------------------------------------- /__tests__/stubs/Desktop 1_083.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/Desktop 1_083.png -------------------------------------------------------------------------------- /__tests__/stubs/LargeTestImage-LargeTestImageFailure-ssim-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/LargeTestImage-LargeTestImageFailure-ssim-diff.png -------------------------------------------------------------------------------- /__tests__/stubs/LargeTestImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/LargeTestImage.png -------------------------------------------------------------------------------- /__tests__/stubs/LargeTestImageFailure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/LargeTestImageFailure.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImage.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImage150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImage150x150.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImageFailure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImageFailure.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImageFailureOversize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImageFailureOversize.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImageUpdate1pxOff-onlyDiff-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImageUpdate1pxOff-onlyDiff-diff.png -------------------------------------------------------------------------------- /__tests__/stubs/TestImageUpdate1pxOff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/__tests__/stubs/TestImageUpdate1pxOff.png -------------------------------------------------------------------------------- /__tests__/stubs/runtimeHooksPath.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | exports.onBeforeWriteToDisc = buffer => buffer; 16 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,either express 11 | * or implied. See the License for the specific language governing permissions and limitations 12 | * under the License. 13 | */ 14 | 15 | module.exports = { 16 | extends: ['@commitlint/config-conventional'], 17 | rules: { 18 | 'scope-case': [2, 'always', ['pascal-case', 'camel-case', 'kebab-case']], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /examples/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex/test" 3 | } -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # jest-image-snapshot example usage 2 | 3 | To run the examples: 4 | ```bash 5 | git clone https://github.com/americanexpress/jest-image-snapshot.git 6 | cd jest-image-snapshot/examples 7 | npm install 8 | npm test 9 | ``` -------------------------------------------------------------------------------- /examples/__tests__/__image_snapshots__/local-image-spec-js-works-reading-an-image-from-the-local-file-system-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/examples/__tests__/__image_snapshots__/local-image-spec-js-works-reading-an-image-from-the-local-file-system-1-snap.png -------------------------------------------------------------------------------- /examples/__tests__/__image_snapshots__/puppeteer-example-spec-js-jest-image-snapshot-usage-with-an-image-received-from-puppeteer-works-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/examples/__tests__/__image_snapshots__/puppeteer-example-spec-js-jest-image-snapshot-usage-with-an-image-received-from-puppeteer-works-1-snap.png -------------------------------------------------------------------------------- /examples/__tests__/local-image.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const fs = require('fs'); 16 | const path = require('path'); 17 | 18 | it('works reading an image from the local file system', () => { 19 | const imageAtTestPath = path.resolve(__dirname, './stubs', 'image.png'); 20 | // imageAtTest is a PNG encoded image buffer which is what `toMatchImageSnapshot() expects 21 | const imageAtTest = fs.readFileSync(imageAtTestPath); 22 | 23 | expect(imageAtTest).toMatchImageSnapshot(); 24 | }); 25 | -------------------------------------------------------------------------------- /examples/__tests__/puppeteer-example.spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | // eslint is looking for `puppeteer` at root level package.json 16 | // eslint-disable-next-line import/no-unresolved 17 | const puppeteer = require('puppeteer'); 18 | 19 | describe('jest-image-snapshot usage with an image received from puppeteer', () => { 20 | let browser; 21 | 22 | beforeAll(async () => { 23 | browser = await puppeteer.launch(); 24 | }); 25 | 26 | it('works', async () => { 27 | const page = await browser.newPage(); 28 | await page.goto('https://example.com'); 29 | const image = await page.screenshot(); 30 | 31 | expect(image).toMatchImageSnapshot(); 32 | }); 33 | 34 | afterAll(async () => { 35 | await browser.close(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /examples/__tests__/stubs/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/examples/__tests__/stubs/image.png -------------------------------------------------------------------------------- /examples/image-reporter.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /* 4 | * To enable this image reporter, add it to your `jest.config.js` "reporters" definition: 5 | "reporters": [ "default", "/image-reporter.js" ] 6 | */ 7 | 8 | const chalk = require('chalk'); 9 | const fs = require('fs'); 10 | const AWS = require('aws-sdk/global'); 11 | const S3 = require('aws-sdk/clients/s3'); 12 | 13 | const UPLOAD_BUCKET = 'YOUR_S3_BUCKET_NAME'; 14 | 15 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }); 16 | 17 | class ImageReporter { 18 | constructor(globalConfig, options) { 19 | this._globalConfig = globalConfig; 20 | this._options = options; 21 | } 22 | 23 | onTestResult(test, testResult, aggregateResults) { 24 | if (testResult.numFailingTests && testResult.failureMessage.match(/different from snapshot/)) { 25 | const files = fs.readdirSync('./__tests__/__image_snapshots__/__diff_output__/'); 26 | files.forEach((value) => { 27 | const path = `diff_output/${value}`; 28 | const params = { 29 | Body: fs.readFileSync(`./__tests__/__image_snapshots__/__diff_output__/${value}`), 30 | Bucket: UPLOAD_BUCKET, 31 | Key: path, 32 | ContentType: 'image/png', 33 | }; 34 | s3.putObject(params, (err) => { 35 | if (err) { 36 | console.log(err, err.stack); 37 | } else { 38 | console.log(chalk.red.bold(`Uploaded image diff file to https://${UPLOAD_BUCKET}.s3.amazonaws.com/${path}`)); 39 | } 40 | }); 41 | }); 42 | } 43 | } 44 | } 45 | 46 | module.exports = ImageReporter; 47 | -------------------------------------------------------------------------------- /examples/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | // eslint runs from root and only looks at root package.json 16 | // eslint-disable-next-line import/no-unresolved 17 | const { toMatchImageSnapshot } = require('jest-image-snapshot'); 18 | 19 | expect.extend({ toMatchImageSnapshot }); 20 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-image-snapshot-examples", 3 | "version": "0.0.0", 4 | "description": "Example jest-image-snapshot usage", 5 | "scripts": { 6 | "test": "jest" 7 | }, 8 | "keywords": [ 9 | "jest-image-snapshot", 10 | "examples" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/americanexpress/jest-image-snapshot.git" 15 | }, 16 | "author": "Andres Escobar (https://github.com/anescobar1991)", 17 | "license": "Apache-2.0", 18 | "dependencies": { 19 | "jest": "*", 20 | "jest-image-snapshot": "*", 21 | "puppeteer": "*" 22 | }, 23 | "jest": { 24 | "setupFilesAfterEnv": ["/jest-setup.js"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /images/create-snapshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/images/create-snapshot.gif -------------------------------------------------------------------------------- /images/fail-snapshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/images/fail-snapshot.gif -------------------------------------------------------------------------------- /images/image-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/images/image-diff.png -------------------------------------------------------------------------------- /images/jest-image-snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/americanexpress/jest-image-snapshot/63637d79d9838adadd8833aff270cefb61ba448b/images/jest-image-snapshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-image-snapshot", 3 | "version": "6.5.1", 4 | "description": "Jest matcher for image comparisons. Most commonly used for visual regression testing.", 5 | "main": "src/index.js", 6 | "engines": { 7 | "node": "^14.15.0 || ^16.10.0 || >=18.0.0" 8 | }, 9 | "scripts": { 10 | "lint": "eslint ./ --ignore-path .gitignore --ext .js", 11 | "test": "jest --ci=false", 12 | "test:lockfile": "lockfile-lint -p package-lock.json -t npm -a npm -o https: -c -i", 13 | "test:git-history": "commitlint --from origin/main --to HEAD", 14 | "posttest": "npm run lint && npm run test:git-history && npm run test:lockfile" 15 | }, 16 | "keywords": [ 17 | "test", 18 | "amex", 19 | "visual testing", 20 | "css", 21 | "jest", 22 | "browser testing" 23 | ], 24 | "jest": { 25 | "preset": "amex-jest-preset", 26 | "collectCoverageFrom": [ 27 | "src/*.js", 28 | "!src/diff-process.js", 29 | "!**/node_modules/**", 30 | "!test-results/**" 31 | ], 32 | "testMatch": [ 33 | "/__tests__/**/*.spec.js" 34 | ], 35 | "coveragePathIgnorePatterns": [ 36 | "/node_modules/", 37 | "/examples" 38 | ] 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/americanexpress/jest-image-snapshot.git" 43 | }, 44 | "author": "Andres Escobar (https://github.com/anescobar1991)", 45 | "license": "Apache-2.0", 46 | "devDependencies": { 47 | "@commitlint/cli": "^17.6.5", 48 | "@commitlint/config-conventional": "^17.7.0", 49 | "@semantic-release/changelog": "^5.0.0", 50 | "@semantic-release/git": "^9.0.0", 51 | "amex-jest-preset": "^6.1.0", 52 | "eslint": "^6.8.0", 53 | "eslint-config-amex": "^7.0.0", 54 | "husky": "^4.2.1", 55 | "image-size": "^0.8.3", 56 | "jest": "^29.7.0", 57 | "jest-snapshot": "^29.0.0", 58 | "lockfile-lint": "^4.14.0", 59 | "mock-spawn": "^0.2.6", 60 | "rimraf": "^5.0.10", 61 | "semantic-release": "^17.0.4" 62 | }, 63 | "dependencies": { 64 | "chalk": "^4.0.0", 65 | "get-stdin": "^5.0.1", 66 | "glur": "^1.1.2", 67 | "lodash": "^4.17.4", 68 | "pixelmatch": "^5.1.0", 69 | "pngjs": "^3.4.0", 70 | "ssim.js": "^3.1.1" 71 | }, 72 | "peerDependencies": { 73 | "jest": ">=20 <=29" 74 | }, 75 | "peerDependenciesMeta": { 76 | "jest": { 77 | "optional": true 78 | } 79 | }, 80 | "husky": { 81 | "hooks": { 82 | "pre-commit": "npm test", 83 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 84 | } 85 | }, 86 | "release": { 87 | "branches": [ 88 | "+([0-9])?(.{+([0-9]),x}).x", 89 | "main", 90 | "next", 91 | "next-major", 92 | { 93 | "name": "beta", 94 | "prerelease": true 95 | }, 96 | { 97 | "name": "alpha", 98 | "prerelease": true 99 | } 100 | ], 101 | "plugins": [ 102 | "@semantic-release/commit-analyzer", 103 | "@semantic-release/release-notes-generator", 104 | "@semantic-release/changelog", 105 | "@semantic-release/npm", 106 | "@semantic-release/git", 107 | "@semantic-release/github" 108 | ] 109 | }, 110 | "overrides": { 111 | "eslint-config-amex": { 112 | "eslint": "$eslint" 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/diff-process.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const fs = require('fs'); 16 | 17 | const getStdin = require('get-stdin'); 18 | 19 | const { diffImageToSnapshot } = require('./diff-snapshot'); 20 | 21 | getStdin.buffer().then((buffer) => { 22 | try { 23 | const options = JSON.parse(buffer); 24 | 25 | options.receivedImageBuffer = Buffer.from(options.receivedImageBuffer, 'base64'); 26 | 27 | const result = diffImageToSnapshot(options); 28 | 29 | fs.writeSync(3, Buffer.from(JSON.stringify(result))); 30 | 31 | process.exit(0); 32 | } catch (error) { 33 | console.error(error); // eslint-disable-line no-console 34 | process.exit(1); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /src/diff-snapshot.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const childProcess = require('child_process'); 16 | const fs = require('fs'); 17 | const path = require('path'); 18 | const pixelmatch = require('pixelmatch'); 19 | const ssim = require('ssim.js'); 20 | const { PNG } = require('pngjs'); 21 | const glur = require('glur'); 22 | const ImageComposer = require('./image-composer'); 23 | 24 | /** 25 | * Helper function to create reusable image resizer 26 | */ 27 | const createImageResizer = (width, height) => (source) => { 28 | const resized = new PNG({ width, height, fill: true }); 29 | PNG.bitblt(source, resized, 0, 0, source.width, source.height, 0, 0); 30 | return resized; 31 | }; 32 | 33 | /** 34 | * Fills diff area with black transparent color for meaningful diff 35 | */ 36 | /* eslint-disable no-plusplus, no-param-reassign, no-bitwise */ 37 | const fillSizeDifference = (width, height) => (image) => { 38 | const inArea = (x, y) => y > height || x > width; 39 | for (let y = 0; y < image.height; y++) { 40 | for (let x = 0; x < image.width; x++) { 41 | if (inArea(x, y)) { 42 | const idx = ((image.width * y) + x) << 2; 43 | image.data[idx] = 0; 44 | image.data[idx + 1] = 0; 45 | image.data[idx + 2] = 0; 46 | image.data[idx + 3] = 64; 47 | } 48 | } 49 | } 50 | return image; 51 | }; 52 | /* eslint-enabled */ 53 | /** 54 | * This was originally embedded in diffImageToSnapshot 55 | * when it only worked with pixelmatch. It has a default 56 | * threshold of 0.01 defined in terms of what it means to pixelmatch. 57 | * It has been moved here as part of the SSIM implementation to make it 58 | * a little easier to read and find. 59 | * More information about this can be found under the options section listed 60 | * in https://github.com/mapbox/pixelmatch/README.md and in the original pixelmatch 61 | * code. There is also some documentation on this in our README.md under the 62 | * customDiffConfig option. 63 | * @type {{threshold: number}} 64 | */ 65 | const defaultPixelmatchDiffConfig = { 66 | threshold: 0.01, 67 | }; 68 | /** 69 | * This is the default SSIM diff configuration 70 | * for the jest-image-snapshot's use of the ssim.js 71 | * library. Bezkrovny is a specific SSIM algorithm optimized 72 | * for speed by downsampling the origin image into a smaller image. 73 | * For the small loss in precision, it is roughly 9x faster than the 74 | * SSIM preset 'fast' -- which is modeled after the original SSIM whitepaper. 75 | * Wang, et al. 2004 on "Image Quality Assessment: From Error Visibility to Structural Similarity" 76 | * (https://github.com/obartra/ssim/blob/master/assets/ssim.pdf) 77 | * Most users will never need or want to change this -- unless -- 78 | * they want to get a better quality generated diff. 79 | * @type {{ssim: string}} 80 | */ 81 | const defaultSSIMDiffConfig = { ssim: 'bezkrovny' }; 82 | 83 | /** 84 | * Helper function for SSIM comparison that allows us to use the existing diff 85 | * config that works with jest-image-snapshot to pass parameters 86 | * that will work with SSIM. It also transforms the parameters to match the spec 87 | * required by the SSIM library. 88 | */ 89 | const ssimMatch = ( 90 | newImageData, 91 | baselineImageData, 92 | diffImageData, 93 | imageWidth, 94 | imageHeight, 95 | diffConfig 96 | ) => { 97 | const newImage = { data: newImageData, width: imageWidth, height: imageHeight }; 98 | const baselineImage = { data: baselineImageData, width: imageWidth, height: imageHeight }; 99 | // eslint-disable-next-line camelcase 100 | const { ssim_map, mssim } = ssim.ssim(newImage, baselineImage, diffConfig); 101 | // Converts the SSIM value to different pixels based on image width and height 102 | // conforms to how pixelmatch works. 103 | const diffPixels = (1 - mssim) * imageWidth * imageHeight; 104 | const diffRgbaPixels = new DataView(diffImageData.buffer, diffImageData.byteOffset); 105 | for (let ln = 0; ln !== imageHeight; ++ln) { 106 | for (let pos = 0; pos !== imageWidth; ++pos) { 107 | const rpos = (ln * imageWidth) + pos; 108 | // initial value is transparent. We'll add in the SSIM offset. 109 | // red (ff) green (00) blue (00) alpha (00) 110 | const diffValue = 0xff000000 + Math.floor(0xff * 111 | (1 - ssim_map.data[ 112 | // eslint-disable-next-line no-mixed-operators 113 | (ssim_map.width * Math.round(ssim_map.height * ln / imageHeight)) + 114 | // eslint-disable-next-line no-mixed-operators 115 | Math.round(ssim_map.width * pos / imageWidth)])); 116 | diffRgbaPixels.setUint32(rpos * 4, diffValue); 117 | } 118 | } 119 | return diffPixels; 120 | }; 121 | 122 | /** 123 | * Aligns images sizes to biggest common value 124 | * and fills new pixels with transparent pixels 125 | */ 126 | const alignImagesToSameSize = (firstImage, secondImage) => { 127 | // Keep original sizes to fill extended area later 128 | const firstImageWidth = firstImage.width; 129 | const firstImageHeight = firstImage.height; 130 | const secondImageWidth = secondImage.width; 131 | const secondImageHeight = secondImage.height; 132 | // Calculate biggest common values 133 | const resizeToSameSize = createImageResizer( 134 | Math.max(firstImageWidth, secondImageWidth), 135 | Math.max(firstImageHeight, secondImageHeight) 136 | ); 137 | // Resize both images 138 | const resizedFirst = resizeToSameSize(firstImage); 139 | const resizedSecond = resizeToSameSize(secondImage); 140 | // Fill resized area with black transparent pixels 141 | return [ 142 | fillSizeDifference(firstImageWidth, firstImageHeight)(resizedFirst), 143 | fillSizeDifference(secondImageWidth, secondImageHeight)(resizedSecond), 144 | ]; 145 | }; 146 | 147 | const isFailure = ({ pass, updateSnapshot }) => !pass && !updateSnapshot; 148 | 149 | const shouldUpdate = ({ pass, updateSnapshot, updatePassedSnapshot }) => 150 | updateSnapshot && (!pass || (pass && updatePassedSnapshot)); 151 | 152 | const shouldFail = ({ 153 | totalPixels, 154 | diffPixelCount, 155 | hasSizeMismatch, 156 | allowSizeMismatch, 157 | failureThresholdType, 158 | failureThreshold, 159 | }) => { 160 | let pass = false; 161 | let diffSize = false; 162 | const diffRatio = diffPixelCount / totalPixels; 163 | if (hasSizeMismatch) { 164 | // do not fail if allowSizeMismatch is set 165 | pass = allowSizeMismatch; 166 | diffSize = true; 167 | } 168 | if (!diffSize || pass === true) { 169 | if (failureThresholdType === 'pixel') { 170 | pass = diffPixelCount <= failureThreshold; 171 | } else if (failureThresholdType === 'percent') { 172 | pass = diffRatio <= failureThreshold; 173 | } else { 174 | throw new Error(`Unknown failureThresholdType: ${failureThresholdType}. Valid options are "pixel" or "percent".`); 175 | } 176 | } 177 | return { 178 | pass, 179 | diffSize, 180 | diffRatio, 181 | }; 182 | }; 183 | 184 | function composeDiff(options) { 185 | const { 186 | diffDirection, baselineImage, diffImage, receivedImage, imageWidth, imageHeight, onlyDiff, 187 | } = options; 188 | const composer = new ImageComposer({ 189 | direction: diffDirection, 190 | }); 191 | 192 | if (onlyDiff) { 193 | composer.addImage(diffImage, imageWidth, imageHeight); 194 | } else { 195 | composer.addImage(baselineImage, imageWidth, imageHeight); 196 | composer.addImage(diffImage, imageWidth, imageHeight); 197 | composer.addImage(receivedImage, imageWidth, imageHeight); 198 | } 199 | return composer; 200 | } 201 | 202 | function writeFileWithHooks({ 203 | pathToFile, 204 | content, 205 | runtimeHooksPath, 206 | testPath, 207 | currentTestName, 208 | }) { 209 | let finalContent = content; 210 | if (runtimeHooksPath) { 211 | let runtimeHooks; 212 | try { 213 | // As `diffImageToSnapshot` can be called in a worker, and as we cannot pass a function 214 | // to a worker, we need to use an external file path that can be imported 215 | // eslint-disable-next-line import/no-dynamic-require, global-require 216 | runtimeHooks = require(runtimeHooksPath); 217 | } catch (e) { 218 | throw new Error(`Couldn't import ${runtimeHooksPath}: ${e.message}`); 219 | } 220 | try { 221 | finalContent = runtimeHooks.onBeforeWriteToDisc({ 222 | buffer: content, 223 | destination: pathToFile, 224 | testPath, 225 | currentTestName, 226 | }); 227 | } catch (e) { 228 | throw new Error(`Couldn't execute onBeforeWriteToDisc: ${e.message}`); 229 | } 230 | } 231 | fs.writeFileSync(pathToFile, finalContent); 232 | } 233 | 234 | function diffImageToSnapshot(options) { 235 | const { 236 | receivedImageBuffer, 237 | snapshotIdentifier, 238 | snapshotsDir, 239 | storeReceivedOnFailure, 240 | receivedPostfix = '-received', 241 | receivedDir = path.join(options.snapshotsDir, '__received_output__'), 242 | diffDir = path.join(options.snapshotsDir, '__diff_output__'), 243 | diffDirection, 244 | onlyDiff = false, 245 | updateSnapshot = false, 246 | updatePassedSnapshot = false, 247 | customDiffConfig = {}, 248 | failureThreshold, 249 | failureThresholdType, 250 | blur, 251 | allowSizeMismatch = false, 252 | comparisonMethod = 'pixelmatch', 253 | testPath, 254 | currentTestName, 255 | runtimeHooksPath, 256 | } = options; 257 | 258 | const comparisonFn = comparisonMethod === 'ssim' ? ssimMatch : pixelmatch; 259 | let result = {}; 260 | const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}.png`); 261 | if (!fs.existsSync(baselineSnapshotPath)) { 262 | fs.mkdirSync(path.dirname(baselineSnapshotPath), { recursive: true }); 263 | writeFileWithHooks({ 264 | pathToFile: baselineSnapshotPath, 265 | content: receivedImageBuffer, 266 | runtimeHooksPath, 267 | testPath, 268 | currentTestName, 269 | }); 270 | result = { added: true }; 271 | } else { 272 | const receivedSnapshotPath = path.join(receivedDir, `${snapshotIdentifier}${receivedPostfix}.png`); 273 | fs.rmSync(receivedSnapshotPath, { recursive: true, force: true }); 274 | 275 | const diffOutputPath = path.join(diffDir, `${snapshotIdentifier}-diff.png`); 276 | fs.rmSync(diffOutputPath, { recursive: true, force: true }); 277 | 278 | const defaultDiffConfig = comparisonMethod !== 'ssim' ? defaultPixelmatchDiffConfig : defaultSSIMDiffConfig; 279 | 280 | const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig); 281 | 282 | const rawReceivedImage = PNG.sync.read(receivedImageBuffer); 283 | const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath)); 284 | const hasSizeMismatch = ( 285 | rawReceivedImage.height !== rawBaselineImage.height || 286 | rawReceivedImage.width !== rawBaselineImage.width 287 | ); 288 | const imageDimensions = { 289 | receivedHeight: rawReceivedImage.height, 290 | receivedWidth: rawReceivedImage.width, 291 | baselineHeight: rawBaselineImage.height, 292 | baselineWidth: rawBaselineImage.width, 293 | }; 294 | // Align images in size if different 295 | const [receivedImage, baselineImage] = hasSizeMismatch 296 | ? alignImagesToSameSize(rawReceivedImage, rawBaselineImage) 297 | : [rawReceivedImage, rawBaselineImage]; 298 | const imageWidth = receivedImage.width; 299 | const imageHeight = receivedImage.height; 300 | 301 | if (typeof blur === 'number' && blur > 0) { 302 | glur(receivedImage.data, imageWidth, imageHeight, blur); 303 | glur(baselineImage.data, imageWidth, imageHeight, blur); 304 | } 305 | 306 | const diffImage = new PNG({ width: imageWidth, height: imageHeight }); 307 | 308 | let diffPixelCount = 0; 309 | 310 | diffPixelCount = comparisonFn( 311 | receivedImage.data, 312 | baselineImage.data, 313 | diffImage.data, 314 | imageWidth, 315 | imageHeight, 316 | diffConfig 317 | ); 318 | 319 | const totalPixels = imageWidth * imageHeight; 320 | 321 | const { 322 | pass, 323 | diffSize, 324 | diffRatio, 325 | } = shouldFail({ 326 | totalPixels, 327 | diffPixelCount, 328 | hasSizeMismatch, 329 | allowSizeMismatch, 330 | failureThresholdType, 331 | failureThreshold, 332 | }); 333 | 334 | if (isFailure({ pass, updateSnapshot })) { 335 | if (storeReceivedOnFailure) { 336 | fs.mkdirSync(path.dirname(receivedSnapshotPath), { recursive: true }); 337 | writeFileWithHooks({ 338 | pathToFile: receivedSnapshotPath, 339 | content: receivedImageBuffer, 340 | runtimeHooksPath, 341 | testPath, 342 | currentTestName, 343 | }); 344 | result = { receivedSnapshotPath }; 345 | } 346 | 347 | fs.mkdirSync(path.dirname(diffOutputPath), { recursive: true }); 348 | const composer = composeDiff({ 349 | diffDirection, baselineImage, diffImage, receivedImage, imageWidth, imageHeight, onlyDiff, 350 | }); 351 | 352 | const composerParams = composer.getParams(); 353 | 354 | const compositeResultImage = new PNG({ 355 | width: composerParams.compositeWidth, 356 | height: composerParams.compositeHeight, 357 | }); 358 | 359 | // copy baseline, diff, and received images into composite result image 360 | composerParams.images.forEach((image, index) => { 361 | PNG.bitblt( 362 | image.imageData, compositeResultImage, 0, 0, image.imageWidth, image.imageHeight, 363 | composerParams.offsetX * index, composerParams.offsetY * index 364 | ); 365 | }); 366 | // Set filter type to Paeth to avoid expensive auto scanline filter detection 367 | // For more information see https://www.w3.org/TR/PNG-Filters.html 368 | const pngBuffer = PNG.sync.write(compositeResultImage, { filterType: 4 }); 369 | writeFileWithHooks({ 370 | pathToFile: diffOutputPath, 371 | content: pngBuffer, 372 | runtimeHooksPath, 373 | testPath, 374 | currentTestName, 375 | }); 376 | 377 | result = { 378 | ...result, 379 | pass: false, 380 | diffSize, 381 | imageDimensions, 382 | diffOutputPath, 383 | diffRatio, 384 | diffPixelCount, 385 | imgSrcString: `data:image/png;base64,${pngBuffer.toString('base64')}`, 386 | }; 387 | } else if (shouldUpdate({ pass, updateSnapshot, updatePassedSnapshot })) { 388 | fs.mkdirSync(path.dirname(baselineSnapshotPath), { recursive: true }); 389 | writeFileWithHooks({ 390 | pathToFile: baselineSnapshotPath, 391 | content: receivedImageBuffer, 392 | runtimeHooksPath, 393 | testPath, 394 | currentTestName, 395 | }); 396 | result = { updated: true }; 397 | } else { 398 | result = { 399 | pass, 400 | diffSize, 401 | diffRatio, 402 | diffPixelCount, 403 | diffOutputPath, 404 | }; 405 | } 406 | } 407 | return result; 408 | } 409 | 410 | 411 | function runDiffImageToSnapshot(options) { 412 | options.receivedImageBuffer = options.receivedImageBuffer.toString('base64'); 413 | 414 | const serializedInput = JSON.stringify(options); 415 | 416 | let result = {}; 417 | 418 | const writeDiffProcess = childProcess.spawnSync( 419 | process.execPath, [`${__dirname}/diff-process.js`], 420 | { 421 | input: Buffer.from(serializedInput), 422 | stdio: ['pipe', 'inherit', 'inherit', 'pipe'], 423 | maxBuffer: options.maxChildProcessBufferSizeInBytes, 424 | } 425 | ); 426 | 427 | if (writeDiffProcess.status === 0) { 428 | const output = writeDiffProcess.output[3].toString(); 429 | result = JSON.parse(output); 430 | } else { 431 | throw new Error(`Error running image diff: ${(writeDiffProcess.error && writeDiffProcess.error.message) || 'Unknown Error'}`); 432 | } 433 | 434 | return result; 435 | } 436 | 437 | module.exports = { 438 | diffImageToSnapshot, 439 | runDiffImageToSnapshot, 440 | }; 441 | -------------------------------------------------------------------------------- /src/image-composer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | const getMaxImageSize = (images) => { 16 | let maxWidth = 0; 17 | let maxHeight = 0; 18 | 19 | images.forEach((image) => { 20 | if (image.imageWidth > maxWidth) { 21 | maxWidth = image.imageWidth; 22 | } 23 | 24 | if (image.imageHeight > maxHeight) { 25 | maxHeight = image.imageHeight; 26 | } 27 | }); 28 | 29 | return { 30 | maxWidth, 31 | maxHeight, 32 | }; 33 | }; 34 | 35 | const ImageComposer = function ImageComposer(options = {}) { 36 | this.direction = options.direction || 'horizontal'; 37 | this.images = []; 38 | 39 | return this; 40 | }; 41 | 42 | ImageComposer.prototype.addImage = function addImage(imageData, imageWidth, imageHeight) { 43 | this.images.push({ 44 | imageData, 45 | imageWidth, 46 | imageHeight, 47 | }); 48 | 49 | return this; 50 | }; 51 | 52 | ImageComposer.prototype.getParams = function getParams() { 53 | const { maxWidth, maxHeight } = getMaxImageSize(this.images); 54 | 55 | const compositeWidth = maxWidth * (this.direction === 'horizontal' ? this.images.length : 1); 56 | const compositeHeight = maxHeight * (this.direction === 'vertical' ? this.images.length : 1); 57 | const offsetX = this.direction === 'horizontal' ? maxWidth : 0; 58 | const offsetY = this.direction === 'vertical' ? maxHeight : 0; 59 | 60 | return { 61 | direction: this.direction, 62 | images: this.images, 63 | imagesCount: this.images.length, 64 | compositeWidth, 65 | compositeHeight, 66 | offsetX, 67 | offsetY, 68 | }; 69 | }; 70 | 71 | module.exports = ImageComposer; 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | /* eslint-disable no-underscore-dangle */ 15 | const kebabCase = require('lodash/kebabCase'); 16 | const merge = require('lodash/merge'); 17 | const path = require('path'); 18 | const Chalk = require('chalk').Instance; 19 | const { diffImageToSnapshot, runDiffImageToSnapshot } = require('./diff-snapshot'); 20 | const fs = require('fs'); 21 | const OutdatedSnapshotReporter = require('./outdated-snapshot-reporter'); 22 | 23 | const timesCalled = new Map(); 24 | 25 | const SNAPSHOTS_DIR = '__image_snapshots__'; 26 | 27 | function toBuffer(data) { 28 | if (data == null || Buffer.isBuffer(data)) { 29 | return data; 30 | } 31 | if (typeof data === 'string') { 32 | return Buffer.from(data, 'base64'); 33 | } 34 | 35 | return Buffer.from(data); 36 | } 37 | 38 | function updateSnapshotState(originalSnapshotState, partialSnapshotState) { 39 | if (global.UNSTABLE_SKIP_REPORTING) { 40 | return originalSnapshotState; 41 | } 42 | return merge(originalSnapshotState, partialSnapshotState); 43 | } 44 | 45 | function checkResult({ 46 | result, 47 | snapshotState, 48 | retryTimes, 49 | snapshotIdentifier, 50 | chalk, 51 | dumpDiffToConsole, 52 | dumpInlineDiffToConsole, 53 | allowSizeMismatch, 54 | }) { 55 | let pass = true; 56 | /* 57 | istanbul ignore next 58 | `message` is implementation detail. Actual behavior is tested in integration.spec.js 59 | */ 60 | let message = () => ''; 61 | 62 | if (result.updated) { 63 | // once transition away from jasmine is done this will be a lot more elegant and pure 64 | // https://github.com/facebook/jest/pull/3668 65 | updateSnapshotState(snapshotState, { updated: snapshotState.updated + 1 }); 66 | } else if (result.added) { 67 | updateSnapshotState(snapshotState, { added: snapshotState.added + 1 }); 68 | } else { 69 | ({ pass } = result); 70 | 71 | if (pass) { 72 | updateSnapshotState(snapshotState, { matched: snapshotState.matched + 1 }); 73 | } else { 74 | const currentRun = timesCalled.get(snapshotIdentifier); 75 | if (!retryTimes || (currentRun > retryTimes)) { 76 | updateSnapshotState(snapshotState, { unmatched: snapshotState.unmatched + 1 }); 77 | } 78 | 79 | const differencePercentage = result.diffRatio * 100; 80 | message = () => { 81 | let failure; 82 | if (result.diffSize && !allowSizeMismatch) { 83 | failure = `Expected image to be the same size as the snapshot (${result.imageDimensions.baselineWidth}x${result.imageDimensions.baselineHeight}), but was different (${result.imageDimensions.receivedWidth}x${result.imageDimensions.receivedHeight}).\n`; 84 | } else { 85 | failure = `Expected image to match or be a close match to snapshot but was ${differencePercentage}% different from snapshot (${result.diffPixelCount} differing pixels).\n`; 86 | } 87 | 88 | failure += `${chalk.bold.red('See diff for details:')} ${chalk.red(result.diffOutputPath)}`; 89 | 90 | const supportedInlineTerms = [ 91 | 'iTerm.app', 92 | 'WezTerm', 93 | ]; 94 | 95 | if (dumpInlineDiffToConsole && (supportedInlineTerms.includes(process.env.TERM_PROGRAM) || 'ENABLE_INLINE_DIFF' in process.env)) { 96 | failure += `\n\n\t\x1b]1337;File=name=${Buffer.from(result.diffOutputPath).toString('base64')};inline=1;width=40:${result.imgSrcString.replace('data:image/png;base64,', '')}\x07\x1b\n\n`; 97 | } else if (dumpDiffToConsole || dumpInlineDiffToConsole) { 98 | failure += `\n${chalk.bold.red('Or paste below image diff string to your browser`s URL bar.')}\n ${result.imgSrcString}`; 99 | } 100 | 101 | return failure; 102 | }; 103 | } 104 | } 105 | 106 | return { 107 | message, 108 | pass, 109 | }; 110 | } 111 | 112 | function createSnapshotIdentifier({ 113 | retryTimes, 114 | testPath, 115 | currentTestName, 116 | customSnapshotIdentifier, 117 | snapshotState, 118 | }) { 119 | const counter = snapshotState._counters.get(currentTestName); 120 | const defaultIdentifier = kebabCase(`${path.basename(testPath)}-${currentTestName}-${counter}`); 121 | 122 | let snapshotIdentifier = customSnapshotIdentifier || `${defaultIdentifier}-snap`; 123 | 124 | if (typeof customSnapshotIdentifier === 'function') { 125 | const customRes = customSnapshotIdentifier({ 126 | testPath, currentTestName, counter, defaultIdentifier, 127 | }); 128 | 129 | if (retryTimes && !customRes) { 130 | throw new Error('A unique customSnapshotIdentifier must be set when jest.retryTimes() is used'); 131 | } 132 | 133 | snapshotIdentifier = customRes || defaultIdentifier; 134 | } 135 | 136 | if (retryTimes) { 137 | if (!customSnapshotIdentifier) throw new Error('A unique customSnapshotIdentifier must be set when jest.retryTimes() is used'); 138 | 139 | timesCalled.set(snapshotIdentifier, (timesCalled.get(snapshotIdentifier) || 0) + 1); 140 | } 141 | 142 | return snapshotIdentifier; 143 | } 144 | 145 | function configureToMatchImageSnapshot({ 146 | customDiffConfig: commonCustomDiffConfig = {}, 147 | customSnapshotIdentifier: commonCustomSnapshotIdentifier, 148 | customSnapshotsDir: commonCustomSnapshotsDir, 149 | storeReceivedOnFailure: commonStoreReceivedOnFailure = false, 150 | customReceivedDir: commonCustomReceivedDir, 151 | customReceivedPostfix: commonCustomReceivedPostfix, 152 | customDiffDir: commonCustomDiffDir, 153 | onlyDiff: commonOnlyDiff = false, 154 | runtimeHooksPath: commonRuntimeHooksPath = undefined, 155 | diffDirection: commonDiffDirection = 'horizontal', 156 | noColors: commonNoColors, 157 | failureThreshold: commonFailureThreshold = 0, 158 | failureThresholdType: commonFailureThresholdType = 'pixel', 159 | updatePassedSnapshot: commonUpdatePassedSnapshot = false, 160 | blur: commonBlur = 0, 161 | runInProcess: commonRunInProcess = false, 162 | dumpDiffToConsole: commonDumpDiffToConsole = false, 163 | dumpInlineDiffToConsole: commonDumpInlineDiffToConsole = false, 164 | allowSizeMismatch: commonAllowSizeMismatch = false, 165 | // Default to 10 MB instead of node's default 1 MB 166 | // See https://nodejs.org/api/child_process.html#child_processspawnsynccommand-args-options 167 | maxChildProcessBufferSizeInBytes: 168 | commonMaxChildProcessBufferSizeInBytes = 10 * 1024 * 1024, // 10 MB 169 | comparisonMethod: commonComparisonMethod = 'pixelmatch', 170 | } = {}) { 171 | return function toMatchImageSnapshot(received, { 172 | customSnapshotIdentifier = commonCustomSnapshotIdentifier, 173 | customSnapshotsDir = commonCustomSnapshotsDir, 174 | storeReceivedOnFailure = commonStoreReceivedOnFailure, 175 | customReceivedDir = commonCustomReceivedDir, 176 | customReceivedPostfix = commonCustomReceivedPostfix, 177 | customDiffDir = commonCustomDiffDir, 178 | onlyDiff = commonOnlyDiff, 179 | runtimeHooksPath = commonRuntimeHooksPath, 180 | diffDirection = commonDiffDirection, 181 | customDiffConfig = {}, 182 | noColors = commonNoColors, 183 | failureThreshold = commonFailureThreshold, 184 | failureThresholdType = commonFailureThresholdType, 185 | updatePassedSnapshot = commonUpdatePassedSnapshot, 186 | blur = commonBlur, 187 | runInProcess = commonRunInProcess, 188 | dumpDiffToConsole = commonDumpDiffToConsole, 189 | dumpInlineDiffToConsole = commonDumpInlineDiffToConsole, 190 | allowSizeMismatch = commonAllowSizeMismatch, 191 | maxChildProcessBufferSizeInBytes = commonMaxChildProcessBufferSizeInBytes, 192 | comparisonMethod = commonComparisonMethod, 193 | } = {}) { 194 | const { 195 | testPath, currentTestName, isNot, snapshotState, 196 | } = this; 197 | const chalkOptions = {}; 198 | if (typeof noColors !== 'undefined') { 199 | // 1 means basic ANSI 16-color support, 0 means no support 200 | chalkOptions.level = noColors ? 0 : 1; 201 | } 202 | const chalk = new Chalk(chalkOptions); 203 | 204 | const retryTimes = parseInt(global[Symbol.for('RETRY_TIMES')], 10) || 0; 205 | 206 | if (isNot) { throw new Error('Jest: `.not` cannot be used with `.toMatchImageSnapshot()`.'); } 207 | 208 | updateSnapshotState(snapshotState, { _counters: snapshotState._counters.set(currentTestName, (snapshotState._counters.get(currentTestName) || 0) + 1) }); // eslint-disable-line max-len 209 | 210 | const snapshotIdentifier = createSnapshotIdentifier({ 211 | retryTimes, 212 | testPath, 213 | currentTestName, 214 | customSnapshotIdentifier, 215 | snapshotState, 216 | }); 217 | 218 | const snapshotsDir = customSnapshotsDir || path.join(path.dirname(testPath), SNAPSHOTS_DIR); 219 | const receivedDir = customReceivedDir; 220 | const receivedPostfix = customReceivedPostfix; 221 | const diffDir = customDiffDir; 222 | const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}.png`); 223 | OutdatedSnapshotReporter.markTouchedFile(baselineSnapshotPath); 224 | 225 | if (snapshotState._updateSnapshot === 'none' && !fs.existsSync(baselineSnapshotPath)) { 226 | return { 227 | pass: false, 228 | message: () => `New snapshot was ${chalk.bold.red('not written')}. The update flag must be explicitly ` + 229 | 'passed to write a new snapshot.\n\n + This is likely because this test is run in a continuous ' + 230 | 'integration (CI) environment in which snapshots are not written by default.\n\n', 231 | }; 232 | } 233 | 234 | const imageToSnapshot = runInProcess ? diffImageToSnapshot : runDiffImageToSnapshot; 235 | 236 | const result = 237 | imageToSnapshot({ 238 | receivedImageBuffer: toBuffer(received), 239 | snapshotsDir, 240 | storeReceivedOnFailure, 241 | receivedDir, 242 | receivedPostfix, 243 | diffDir, 244 | diffDirection, 245 | testPath, 246 | currentTestName, 247 | onlyDiff, 248 | snapshotIdentifier, 249 | updateSnapshot: snapshotState._updateSnapshot === 'all', 250 | customDiffConfig: Object.assign({}, commonCustomDiffConfig, customDiffConfig), 251 | failureThreshold, 252 | failureThresholdType, 253 | updatePassedSnapshot, 254 | blur, 255 | allowSizeMismatch, 256 | maxChildProcessBufferSizeInBytes, 257 | comparisonMethod, 258 | runtimeHooksPath, 259 | }); 260 | 261 | return checkResult({ 262 | result, 263 | snapshotState, 264 | retryTimes, 265 | snapshotIdentifier, 266 | chalk, 267 | dumpDiffToConsole, 268 | dumpInlineDiffToConsole, 269 | allowSizeMismatch, 270 | }); 271 | }; 272 | } 273 | 274 | module.exports = { 275 | toMatchImageSnapshot: configureToMatchImageSnapshot(), 276 | configureToMatchImageSnapshot, 277 | updateSnapshotState, 278 | }; 279 | -------------------------------------------------------------------------------- /src/outdated-snapshot-reporter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 American Express Travel Related Services Company, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | /* eslint-disable class-methods-use-this */ 16 | 17 | const fs = require('fs'); 18 | const path = require('path'); 19 | 20 | const TOUCHED_FILE_LIST_PATH = path.join( 21 | process.cwd(), 22 | '.jest-image-snapshot-touched-files' 23 | ); 24 | 25 | const IS_ENABLED = !!process.env.JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE; 26 | 27 | class OutdatedSnapshotReporter { 28 | /* istanbul ignore next - test coverage in child process */ 29 | static markTouchedFile(filePath) { 30 | if (!IS_ENABLED) return; 31 | const touchedListFileDescriptor = fs.openSync(TOUCHED_FILE_LIST_PATH, 'as'); 32 | fs.writeSync(touchedListFileDescriptor, `${filePath}\n`); 33 | fs.closeSync(touchedListFileDescriptor); 34 | } 35 | 36 | /* istanbul ignore next - test coverage in child process */ 37 | static readTouchedFileListFromDisk() { 38 | if (!fs.existsSync(TOUCHED_FILE_LIST_PATH)) return []; 39 | 40 | return Array.from( 41 | new Set( 42 | fs 43 | .readFileSync(TOUCHED_FILE_LIST_PATH, 'utf-8') 44 | .split('\n') 45 | .filter(file => file && fs.existsSync(file)) 46 | ) 47 | ); 48 | } 49 | 50 | /* istanbul ignore next - test coverage in child process */ 51 | onRunStart() { 52 | if (!IS_ENABLED) return; 53 | if (fs.existsSync(TOUCHED_FILE_LIST_PATH)) { 54 | fs.unlinkSync(TOUCHED_FILE_LIST_PATH); 55 | } 56 | } 57 | 58 | /* istanbul ignore next - test coverage in child process */ 59 | onRunComplete() { 60 | if (!IS_ENABLED) return; 61 | const touchedFiles = OutdatedSnapshotReporter.readTouchedFileListFromDisk(); 62 | const imageSnapshotDirectories = Array.from( 63 | new Set(touchedFiles.map(file => path.dirname(file))) 64 | ); 65 | const allFiles = imageSnapshotDirectories 66 | .map(dir => fs.readdirSync(dir).map(file => path.join(dir, file))) 67 | .reduce((a, b) => a.concat(b), []) 68 | .filter(file => file.endsWith('-snap.png')); 69 | const obsoleteFiles = allFiles.filter( 70 | file => !touchedFiles.includes(file) 71 | ); 72 | 73 | if (fs.existsSync(TOUCHED_FILE_LIST_PATH)) { 74 | fs.unlinkSync(TOUCHED_FILE_LIST_PATH); 75 | } 76 | 77 | obsoleteFiles.forEach((file) => { 78 | process.stderr.write(`Deleting outdated snapshot "${file}"...\n`); 79 | fs.unlinkSync(file); 80 | }); 81 | } 82 | } 83 | 84 | module.exports = OutdatedSnapshotReporter; 85 | --------------------------------------------------------------------------------