Skip to content

Commit d1a9b87

Browse files
committed
feat(vr-assert): implement visual-regression-assert cli
1 parent 1380089 commit d1a9b87

23 files changed

+1018
-3
lines changed

.github/CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ scripts/update-release-notes @microsoft/fluentui-react-build
101101
scripts/utils @microsoft/fluentui-react-build
102102
scripts/webpack @microsoft/fluentui-react-build
103103
scripts/perf-test-flamegrill @microsoft/fluentui-react-build
104+
tools/visual-regression-assert @microsoft/fluentui-react-build
104105

105106
#### Fluent UI N*
106107
packages/a11y-rules @microsoft/fluentui-northstar
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"extends": ["plugin:@fluentui/eslint-plugin/node"],
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {}
8+
},
9+
{
10+
"files": ["*.ts", "*.tsx"],
11+
"rules": {}
12+
},
13+
{
14+
"files": ["*.js", "*.jsx"],
15+
"rules": {}
16+
},
17+
{
18+
"files": ["*.json"],
19+
"parser": "jsonc-eslint-parser",
20+
"rules": {}
21+
}
22+
]
23+
}

tools/visual-regression-assert/.swcrc

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"jsc": {
3+
"target": "es2017",
4+
"parser": {
5+
"syntax": "typescript",
6+
"decorators": true,
7+
"dynamicImport": false
8+
},
9+
"transform": {
10+
"decoratorMetadata": true,
11+
"legacyDecorator": true
12+
},
13+
"keepClassNames": true,
14+
"externalHelpers": true,
15+
"loose": true
16+
},
17+
"module": {
18+
"type": "commonjs",
19+
"ignoreDynamic": true
20+
},
21+
"sourceMaps": true,
22+
"exclude": [
23+
"jest.config.ts",
24+
".*\\.spec.tsx?$",
25+
".*\\.test.tsx?$",
26+
"./src/jest-setup.ts$",
27+
"./**/jest-setup.ts$",
28+
".*.js$"
29+
]
30+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# visual-regression-assert
2+
3+
This library was generated with [Nx](https://nx.dev).
4+
5+
## Building
6+
7+
Run `nx build visual-regression-assert` to build the library.
8+
9+
## Running unit tests
10+
11+
Run `nx test visual-regression-assert` to execute the unit tests via [Jest](https://jestjs.io).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env node
2+
3+
// @ts-check
4+
5+
const { join } = require('node:path');
6+
const { registerTsProject } = require('@nx/js/src/internal');
7+
8+
// TODO: we need SWC in memory transpile as TSC doesn't support ignoring dynamic import during transpilations which is crucial for CJS module that needs to consume ESM ( in our case pixelmatch )
9+
10+
// registerTsProject(join(__dirname, '../tsconfig.lib.json'));
11+
// require('../src/cli');
12+
13+
require('../dist/src/cli');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* eslint-disable */
2+
import { readFileSync } from 'node:fs';
3+
const { join } = require('node:path');
4+
5+
// Reading the SWC compilation config and remove the "exclude"
6+
// for the test files to be compiled by SWC
7+
const { exclude: _, ...swcJestConfig } = JSON.parse(readFileSync(join(__dirname, '.swcrc'), 'utf-8'));
8+
9+
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
10+
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
11+
if (swcJestConfig.swcrc === undefined) {
12+
swcJestConfig.swcrc = false;
13+
}
14+
15+
// Uncomment if using global setup/teardown files being transformed via swc
16+
// https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries
17+
// jest needs EsModule Interop to find the default exported setup/teardown functions
18+
// swcJestConfig.module.noInterop = false;
19+
20+
export default {
21+
displayName: 'visual-regression-assert',
22+
preset: '../../jest.preset.js',
23+
transform: {
24+
'^.+\\.tsx?$': ['@swc/jest', swcJestConfig],
25+
},
26+
moduleFileExtensions: ['ts', 'tsx', 'js'],
27+
testEnvironment: 'node',
28+
coverageDirectory: '../../coverage/tools/visual-regression-assert',
29+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@fluentui/visual-regression-assert",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "commonjs",
6+
"bin": {
7+
"visual-regression-assert": "./bin/visual-regression-assert.js"
8+
},
9+
"main": "./src/index.js",
10+
"typings": "./src/index.d.ts",
11+
"files": [
12+
"*.md",
13+
"dist/*.d.ts",
14+
"lib",
15+
"lib-commonjs"
16+
],
17+
"dependencies": {
18+
"@swc/helpers": "^0.5.1",
19+
"pixelmatch": "^7.1.0",
20+
"ejs": "^3.1.7",
21+
"glob": "^11.0.1",
22+
"find-up": "5.0.0",
23+
"cli-table3": "^0.6.5"
24+
},
25+
"devDependencies": {
26+
"@types/ejs": "3.1.5"
27+
},
28+
"peerDependencies": {}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "visual-regression-assert",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "tools/visual-regression-assert/src",
5+
"projectType": "library",
6+
"tags": ["platform:any", "tools"],
7+
"targets": {
8+
"build": {
9+
"executor": "@nx/js:swc",
10+
"outputs": ["{options.outputPath}"],
11+
"options": {
12+
"outputPath": "{projectRoot}/dist",
13+
"main": "tools/visual-regression-assert/src/index.ts",
14+
"tsConfig": "tools/visual-regression-assert/tsconfig.lib.json",
15+
"assets": ["tools/visual-regression-assert/*.md", "tools/visual-regression-assert/src/template/*"]
16+
}
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { join } from 'node:path';
2+
import {
3+
copyFileSync,
4+
existsSync,
5+
mkdirSync,
6+
readFileSync,
7+
readdirSync,
8+
rmSync,
9+
unlinkSync,
10+
writeFileSync,
11+
} from 'node:fs';
12+
import { cwd } from 'node:process';
13+
14+
import { PNG } from 'pngjs';
15+
16+
import { getPackageMetadata, loadPixelmatch } from './utils';
17+
import { Metadata, Result } from './types';
18+
import { generateCliReport, generateHtmlReport, generateJsonReport, generateMarkdownReport } from './reporters';
19+
20+
async function compareSnapshots(
21+
baselinePath: string,
22+
actualPath: string,
23+
diffPath: string,
24+
): Promise<Omit<Result, 'file'>> {
25+
try {
26+
const baselineImg = PNG.sync.read(readFileSync(baselinePath));
27+
const actualImg = PNG.sync.read(readFileSync(actualPath));
28+
const { width, height } = baselineImg;
29+
30+
if (actualImg.width !== width || actualImg.height !== height) {
31+
return { passed: false, error: 'Image dimensions mismatch', changeType: 'dimensions-diff' };
32+
}
33+
34+
const diff = new PNG({ width, height });
35+
const pixelmatch = await loadPixelmatch();
36+
const numDiffPixels = pixelmatch(baselineImg.data, actualImg.data, diff.data, width, height, { threshold: 0.1 });
37+
38+
if (numDiffPixels > 0) {
39+
writeFileSync(diffPath, PNG.sync.write(diff));
40+
return {
41+
passed: false,
42+
error: `Diff pixels: ${numDiffPixels}`,
43+
changeType: 'diff',
44+
diffPixels: numDiffPixels,
45+
diffPath: diffPath,
46+
};
47+
}
48+
49+
if (existsSync(diffPath)) {
50+
unlinkSync(diffPath);
51+
}
52+
53+
return { passed: true };
54+
} catch (error) {
55+
console.error(error);
56+
return { passed: false, error: (error as Error).message };
57+
}
58+
}
59+
60+
export async function runSnapshotTests(options: {
61+
baselineDir: string;
62+
outputPath: string;
63+
reportFileName: string;
64+
updateSnapshots: boolean;
65+
}) {
66+
const { updateSnapshots, reportFileName, baselineDir, outputPath } = options;
67+
68+
const relativePaths = {
69+
baselineDir,
70+
outputPath,
71+
outputBaselineDir: join(outputPath, 'baseline'),
72+
actualDir: join(outputPath, 'actual'),
73+
diffDir: join(outputPath, 'diff'),
74+
};
75+
76+
const normalizedPaths = {
77+
outputPath: join(cwd(), outputPath),
78+
baselineDir: join(cwd(), relativePaths.baselineDir),
79+
outputBaselineDir: join(cwd(), relativePaths.outputBaselineDir),
80+
actualDir: join(cwd(), relativePaths.actualDir),
81+
diffDir: join(cwd(), relativePaths.diffDir),
82+
};
83+
84+
if (!existsSync(normalizedPaths.actualDir)) {
85+
throw new Error(
86+
`actualDir "${normalizedPaths.actualDir}" doesn't exist. Make sure to provide images for assertion`,
87+
);
88+
}
89+
90+
const metadata: Metadata = {
91+
paths: normalizedPaths,
92+
project: getPackageMetadata(normalizedPaths.outputPath),
93+
};
94+
95+
if (updateSnapshots) {
96+
console.info('======================');
97+
console.info('💡 UPDATING SNAPSHOTS!');
98+
console.info('======================');
99+
}
100+
101+
if (!existsSync(normalizedPaths.baselineDir)) {
102+
mkdirSync(normalizedPaths.baselineDir, { recursive: true });
103+
}
104+
105+
if (!existsSync(normalizedPaths.outputBaselineDir)) {
106+
mkdirSync(normalizedPaths.outputBaselineDir, { recursive: true });
107+
} else {
108+
rmSync(normalizedPaths.outputBaselineDir, { recursive: true });
109+
mkdirSync(normalizedPaths.outputBaselineDir, { recursive: true });
110+
}
111+
112+
if (!existsSync(normalizedPaths.diffDir)) {
113+
mkdirSync(normalizedPaths.diffDir, { recursive: true });
114+
} else {
115+
rmSync(normalizedPaths.diffDir, { recursive: true });
116+
mkdirSync(normalizedPaths.diffDir, { recursive: true });
117+
}
118+
119+
const baselineFiles = readdirSync(normalizedPaths.baselineDir);
120+
const actualFiles = readdirSync(normalizedPaths.actualDir);
121+
let allPassed = true;
122+
const results: Result[] = [];
123+
124+
const removedFilesFromBaseline = baselineFiles.filter(file => {
125+
if (!file.endsWith('.png')) {
126+
throw new Error(`Only png files are supported - ${file}`);
127+
}
128+
129+
if (!actualFiles.includes(file)) {
130+
const baselinePath = join(normalizedPaths.baselineDir, file);
131+
if (!updateSnapshots) {
132+
// copy baseline img that is being removed to our reports /baseline folder
133+
copyFileSync(baselinePath, join(normalizedPaths.outputBaselineDir, file));
134+
results.push({
135+
file,
136+
passed: updateSnapshots ? true : false,
137+
error: updateSnapshots ? undefined : 'Remove Snapshot',
138+
changeType: 'remove',
139+
});
140+
allPassed = false;
141+
} else {
142+
unlinkSync(baselinePath);
143+
}
144+
}
145+
});
146+
147+
if (removedFilesFromBaseline.length > 0) {
148+
console.error(`🧹 Removed snapshots: ${removedFilesFromBaseline.join(', ')}`);
149+
}
150+
151+
for (const file of actualFiles) {
152+
if (!file.endsWith('.png')) {
153+
throw new Error(`Only png files are supported - ${file}`);
154+
}
155+
156+
const baselinePath = join(normalizedPaths.baselineDir, file);
157+
const actualPath = join(normalizedPaths.actualDir, file);
158+
const diffPath = join(normalizedPaths.diffDir, file);
159+
160+
if (!existsSync(baselinePath)) {
161+
results.push({
162+
file,
163+
passed: updateSnapshots ? true : false,
164+
error: updateSnapshots ? undefined : 'New Snapshot',
165+
changeType: 'add',
166+
});
167+
168+
if (!updateSnapshots) {
169+
allPassed = false;
170+
} else {
171+
copyFileSync(actualPath, baselinePath);
172+
}
173+
continue;
174+
}
175+
176+
if (!updateSnapshots) {
177+
const result = await compareSnapshots(baselinePath, actualPath, diffPath);
178+
if (!result.passed) {
179+
copyFileSync(baselinePath, join(normalizedPaths.outputBaselineDir, file));
180+
allPassed = false;
181+
}
182+
results.push({ file, ...result });
183+
} else {
184+
copyFileSync(actualPath, baselinePath);
185+
results.push({ file, passed: true });
186+
}
187+
}
188+
189+
const reportConfig = {
190+
metadata,
191+
reportFileName,
192+
paths: { absolute: normalizedPaths, relative: relativePaths },
193+
};
194+
195+
generateCliReport(results, reportConfig);
196+
generateJsonReport(results, reportConfig);
197+
generateHtmlReport(results, reportConfig);
198+
generateMarkdownReport(results, reportConfig);
199+
200+
if (!allPassed) {
201+
return { passed: false };
202+
}
203+
204+
return { passed: true };
205+
}

0 commit comments

Comments
 (0)