Skip to content

Commit 47da7c2

Browse files
feat: add obsolete snapshot reporting (#222)
Co-authored-by: Andres Escobar <andres.escobar@aexp.com>
1 parent 621d2f3 commit 47da7c2

File tree

5 files changed

+216
-0
lines changed

5 files changed

+216
-0
lines changed

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,28 @@ expect.extend({ toMatchImageSnapshot });
160160
### jest.retryTimes()
161161
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.
162162

163+
### Removing Outdated Snapshots
164+
165+
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`.
166+
167+
```json
168+
{
169+
"jest": {
170+
"reporters": [
171+
"default",
172+
"jest-image-snapshot/src/outdated-snapshot-reporter.js"
173+
]
174+
}
175+
}
176+
```
177+
178+
**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.**
179+
180+
```bash
181+
export JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE=1
182+
jest
183+
```
184+
163185
### Recommendations when using SSIM comparison
164186
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:
165187
- Reduced false positives (failing tests when the images look the same)
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
const rimraf = require('rimraf');
20+
21+
describe('OutdatedSnapshotReporter', () => {
22+
const jestImageSnapshotDir = path.join(__dirname, '..');
23+
const imagePath = path.join(__dirname, 'stubs/TestImage.png');
24+
const jestBinPath = path.join(jestImageSnapshotDir, 'node_modules/.bin/jest');
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}');
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+
return childProcess.spawnSync(jestBinPath, cliArgs, {
60+
cwd: tmpDir,
61+
encoding: 'utf-8',
62+
env: { ...process.env, ...environment },
63+
});
64+
}
65+
66+
function getSnapshotFiles() {
67+
return fs.readdirSync(path.join(tmpDir, '__image_snapshots__'));
68+
}
69+
70+
beforeAll(() => {
71+
tmpDir = fs.mkdtempSync(
72+
path.join(os.tmpdir(), 'jest-image-snapshot-tests')
73+
);
74+
setupTestProject(tmpDir);
75+
});
76+
77+
afterAll(() => {
78+
rimraf.sync(tmpDir);
79+
});
80+
81+
it('should write the image snapshot on first run', () => {
82+
const { status, stdout, stderr } = runJest(['-u']);
83+
expect(stderr).toContain('snapshots written');
84+
expect(status).toEqual(0);
85+
expect(stdout).toEqual('');
86+
87+
expect(getSnapshotFiles()).toHaveLength(3);
88+
});
89+
90+
it('should not delete the snapshot when environment flag is not enabled', () => {
91+
const { status, stdout } = runJest(['-u', 'image.test.js']);
92+
expect(status).toEqual(0);
93+
expect(stdout).toEqual('');
94+
95+
expect(getSnapshotFiles()).toHaveLength(3);
96+
});
97+
98+
it('should delete the snapshot when environment flag is enabled', () => {
99+
const { status, stdout, stderr } = runJest(['-u', 'image.test.js'], {
100+
JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE: '1',
101+
});
102+
expect(stderr).toContain('outdated snapshot');
103+
expect(status).toEqual(0);
104+
expect(stdout).toEqual('');
105+
106+
expect(getSnapshotFiles()).toHaveLength(1);
107+
});
108+
});

src/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const path = require('path');
1818
const Chalk = require('chalk').constructor;
1919
const { diffImageToSnapshot, runDiffImageToSnapshot } = require('./diff-snapshot');
2020
const fs = require('fs');
21+
const OutdatedSnapshotReporter = require('./outdated-snapshot-reporter');
2122

2223
const timesCalled = new Map();
2324

@@ -180,6 +181,7 @@ function configureToMatchImageSnapshot({
180181
const snapshotsDir = customSnapshotsDir || path.join(path.dirname(testPath), SNAPSHOTS_DIR);
181182
const diffDir = customDiffDir || path.join(snapshotsDir, '__diff_output__');
182183
const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`);
184+
OutdatedSnapshotReporter.markTouchedFile(baselineSnapshotPath);
183185

184186
if (snapshotState._updateSnapshot === 'none' && !fs.existsSync(baselineSnapshotPath)) {
185187
return {

src/outdated-snapshot-reporter.js

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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;

0 commit comments

Comments
 (0)