vitest-plugin-vis 5.1.7 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +18 -0
- package/.codex-plugin/plugin.json +18 -0
- package/.cursor-plugin/plugin.json +18 -0
- package/dist/client/external/browser/image_data.mjs +18 -16
- package/dist/client/external/browser/image_data.mjs.map +1 -1
- package/dist/client/snapshot/compare_image_snapshot.mjs +31 -2
- package/dist/client/snapshot/compare_image_snapshot.mjs.map +1 -1
- package/dist/config/types.d.mts +2 -2
- package/dist/config/types.d.mts.map +1 -1
- package/dist/exports/client-api.d.mts +3 -3
- package/dist/exports/client-api.mjs +2 -2
- package/dist/exports/server-utils.d.mts +2 -2
- package/dist/exports/server-utils.mjs +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/server/testing/stubBrowserCommandContext.mjs +1 -1
- package/dist/server/testing/stubBrowserCommandContext.mjs.map +1 -1
- package/dist/server/testing/stubUserConfig.mjs +1 -1
- package/dist/server/testing/stubUserConfig.mjs.map +1 -1
- package/dist/server/vis_options.d.mts +4 -0
- package/dist/server/vis_server_context.logic.mjs +1 -1
- package/dist/server/vis_server_context.logic.mjs.map +1 -1
- package/dist/shared/changed_regions.mjs +143 -0
- package/dist/shared/changed_regions.mjs.map +1 -0
- package/dist/shared/compare_image.mjs +26 -1
- package/dist/shared/compare_image.mjs.map +1 -1
- package/dist/shared/constants.d.mts +3 -1
- package/dist/shared/constants.d.mts.map +1 -1
- package/dist/shared/constants.mjs +3 -1
- package/dist/shared/constants.mjs.map +1 -1
- package/dist/shared/review_image.mjs +160 -0
- package/dist/shared/review_image.mjs.map +1 -0
- package/dist/shared/types.d.mts +19 -4
- package/dist/shared/types.d.mts.map +1 -1
- package/llms.txt +54 -0
- package/package.json +10 -4
- package/plugin.json +24 -0
- package/skills/review-image-snapshot-failures/README.md +18 -0
- package/skills/review-image-snapshot-failures/SKILL.md +37 -0
- package/src/client/expect/to_match_image_snapshot.mdx +33 -0
- package/src/client/external/browser/image_data.ts +20 -19
- package/src/client/snapshot/compare_image_snapshot.ts +40 -2
- package/src/config/types.ts +3 -1
- package/src/config/vis.mdx +21 -0
- package/src/exports/server-utils.ts +7 -1
- package/src/overview.mdx +2 -0
- package/src/server/testing/stubBrowserCommandContext.ts +1 -1
- package/src/server/testing/stubUserConfig.ts +1 -1
- package/src/server/vis_server_context.logic.ts +1 -0
- package/src/shared/changed_regions.ts +163 -0
- package/src/shared/compare_image.ts +33 -1
- package/src/shared/constants.ts +2 -0
- package/src/shared/review_image.ts +180 -0
- package/src/shared/types.ts +21 -2
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import dedent from 'dedent'
|
|
2
2
|
import { resolve } from 'pathe'
|
|
3
3
|
import type { BrowserCommands } from 'vitest/browser'
|
|
4
|
+
import { findChangedRegions, formatChangedRegions } from '../../shared/changed_regions.ts'
|
|
4
5
|
import type {
|
|
5
6
|
ImageSnapshotNextIndexCommand,
|
|
6
7
|
PrepareImageSnapshotComparisonCommand,
|
|
7
8
|
} from '../../shared/commands.types.ts'
|
|
8
|
-
import { compareImage } from '../../shared/compare_image.ts'
|
|
9
|
+
import { compareImage, getChangedMask } from '../../shared/compare_image.ts'
|
|
10
|
+
import { CHANGED_REGIONS_SUFFIX, REVIEW_IMAGE_SUFFIX } from '../../shared/constants.ts'
|
|
11
|
+
import { createReviewImage, getReviewImageSkipReason } from '../../shared/review_image.ts'
|
|
9
12
|
import type { ImageSnapshotComparisonInfo, ToMatchImageSnapshotOptions } from '../../shared/types.ts'
|
|
10
13
|
import { toDataURL, toImageData } from '../external/browser/image_data.ts'
|
|
11
14
|
import { server } from '../external/vitest/vitest_browser_context_proxy.ts'
|
|
@@ -68,6 +71,36 @@ export async function compareImageSnapshot(
|
|
|
68
71
|
|
|
69
72
|
await writeSnapshot(commands, resolve(info.projectRoot, info.diffPath), diffImage)
|
|
70
73
|
|
|
74
|
+
const mask = getChangedMask(
|
|
75
|
+
baselineAlignedImage.data,
|
|
76
|
+
resultAlignedImage.data,
|
|
77
|
+
diffImage.data,
|
|
78
|
+
width,
|
|
79
|
+
height,
|
|
80
|
+
options as ToMatchImageSnapshotOptions<any>,
|
|
81
|
+
)
|
|
82
|
+
const regions = findChangedRegions(mask, width, height)
|
|
83
|
+
await commands.writeFile(
|
|
84
|
+
resolve(info.projectRoot, toSiblingPath(info.diffPath, CHANGED_REGIONS_SUFFIX)),
|
|
85
|
+
JSON.stringify({ width, height, regions }),
|
|
86
|
+
)
|
|
87
|
+
let review = ''
|
|
88
|
+
if (options?.reviewImage !== false) {
|
|
89
|
+
const skipReason = getReviewImageSkipReason({ baseline: baselineImage, result: resultImage, regions })
|
|
90
|
+
if (skipReason) {
|
|
91
|
+
review = `\nReview: skipped, ${skipReason}; open Actual or Difference instead`
|
|
92
|
+
} else {
|
|
93
|
+
const reviewPath = toSiblingPath(info.diffPath, REVIEW_IMAGE_SUFFIX)
|
|
94
|
+
const reviewImage = createReviewImage(baselineAlignedImage, resultAlignedImage, mask, regions)
|
|
95
|
+
await writeSnapshot(
|
|
96
|
+
commands,
|
|
97
|
+
resolve(info.projectRoot, reviewPath),
|
|
98
|
+
new ImageData(reviewImage.data, reviewImage.width, reviewImage.height),
|
|
99
|
+
)
|
|
100
|
+
review = `\nReview: ${formatSnapshotPath(info.projectRoot, reviewPath)} (baseline, result, diff of each region)`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
throw new Error(
|
|
72
105
|
dedent`Snapshot \`${taskId}\` mismatched
|
|
73
106
|
|
|
@@ -87,10 +120,15 @@ export async function compareImageSnapshot(
|
|
|
87
120
|
|
|
88
121
|
Expected: ${formatSnapshotPath(info.projectRoot, info.baselinePath)}
|
|
89
122
|
Actual: ${formatSnapshotPath(info.projectRoot, info.resultPath)}
|
|
90
|
-
Difference: ${formatSnapshotPath(info.projectRoot, info.diffPath)}
|
|
123
|
+
Difference: ${formatSnapshotPath(info.projectRoot, info.diffPath)}
|
|
124
|
+
Changed: ${formatChangedRegions(regions)}${review}`,
|
|
91
125
|
)
|
|
92
126
|
}
|
|
93
127
|
|
|
128
|
+
function toSiblingPath(diffPath: string, suffix: string) {
|
|
129
|
+
return diffPath.replace(/\.png$/, suffix)
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
async function writeSnapshot(commands: BrowserCommands, path: string, image: ImageData) {
|
|
95
133
|
const content = (await toDataURL(image)).split(',')[1]!
|
|
96
134
|
return commands.writeFile(path, content, { encoding: 'base64' })
|
package/src/config/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ImageSnapshotTimeoutOptions,
|
|
7
7
|
PixelComparisonOptions,
|
|
8
8
|
PixelDiffOptions,
|
|
9
|
+
ReviewImageOptions,
|
|
9
10
|
SsimComparisonOptions,
|
|
10
11
|
SsimDiffOptions,
|
|
11
12
|
} from '../shared/types.ts'
|
|
@@ -30,7 +31,8 @@ export type VisOptions<M extends ComparisonMethod = 'pixel'> = (M extends 'ssim'
|
|
|
30
31
|
FailureThresholdOptions &
|
|
31
32
|
ImageSnapshotTimeoutOptions &
|
|
32
33
|
ImageSnapshotKeyOptions &
|
|
33
|
-
ImageSnapshotSubjectOptions &
|
|
34
|
+
ImageSnapshotSubjectOptions &
|
|
35
|
+
ReviewImageOptions & {
|
|
34
36
|
/**
|
|
35
37
|
* Loads the `test.setupFiles` of the specified preset.
|
|
36
38
|
*
|
package/src/config/vis.mdx
CHANGED
|
@@ -278,6 +278,27 @@ export default defineConfig({
|
|
|
278
278
|
})
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
+
## Review Image
|
|
282
|
+
|
|
283
|
+
When a snapshot mismatches, `vitest-plugin-vis` writes a review image (`<name>.review.png`) next to the diff image.
|
|
284
|
+
It places the baseline, result, and diff crops of each changed region side by side,
|
|
285
|
+
so you or a coding agent can see what changed without opening the full images.
|
|
286
|
+
The changed regions are also written to `<name>.regions.json`.
|
|
287
|
+
See [`toMatchImageSnapshot()`](?path=/docs/expect-tomatchimagesnapshot--docs) for details.
|
|
288
|
+
|
|
289
|
+
To not write the review image:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// vitest.config.ts
|
|
293
|
+
import { vis } from 'vitest-plugin-vis/config'
|
|
294
|
+
|
|
295
|
+
export default defineConfig({
|
|
296
|
+
plugins: [vis({
|
|
297
|
+
reviewImage: false
|
|
298
|
+
})],
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
281
302
|
## Timeout Configuration
|
|
282
303
|
|
|
283
304
|
Sometimes your tests need a bit more time to render, especially if you're working with complex components or slower environments. You can adjust the timeout to give your tests the breathing room they need.
|
|
@@ -3,5 +3,11 @@ export {
|
|
|
3
3
|
resolveLegacySnapshotFileRelativePath,
|
|
4
4
|
resolveSnapshotSubpathWithinLimits,
|
|
5
5
|
} from '../server/snapshot_path_limits.ts'
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
BASELINE_DIR,
|
|
8
|
+
CHANGED_REGIONS_SUFFIX,
|
|
9
|
+
DIFF_DIR,
|
|
10
|
+
REVIEW_IMAGE_SUFFIX,
|
|
11
|
+
SNAPSHOT_ROOT_DIR,
|
|
12
|
+
} from '../shared/constants.ts'
|
|
7
13
|
export { trimCommonFolder } from '../shared/trim_common_folder.ts'
|
package/src/overview.mdx
CHANGED
|
@@ -82,6 +82,8 @@ In each platform directory, there are 3 directories:
|
|
|
82
82
|
- `__baselines__`: The directory for the baseline snapshots. This should be committed to the repository (except for `local`).
|
|
83
83
|
- `__results__`: The directory for the current snapshots created when Vitest runs.
|
|
84
84
|
- `__diffs__`: The directory for the diffs between the current snapshot and the baseline snapshot.
|
|
85
|
+
For each failing snapshot, it also holds the changed regions (`<name>.regions.json`)
|
|
86
|
+
and a review image of the changed regions (`<name>.review.png`).
|
|
85
87
|
|
|
86
88
|
Because of that, you should add the following lines to your `.gitignore` file:
|
|
87
89
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '@vitest/browser-playwright'
|
|
2
|
-
import { stub } from 'type-plus'
|
|
2
|
+
import { stub } from 'type-plus/testing'
|
|
3
3
|
import type { ExtendedBrowserCommandContext } from '../vis_server_context.types.ts'
|
|
4
4
|
|
|
5
5
|
export const stubBrowserCommandContext = stub.build<ExtendedBrowserCommandContext>({})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rectangular area of an image, in pixels.
|
|
3
|
+
*/
|
|
4
|
+
export type ChangedRegion = {
|
|
5
|
+
x: number
|
|
6
|
+
y: number
|
|
7
|
+
width: number
|
|
8
|
+
height: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How far, in pixels, the changed-pixel mask grows before it is split into regions.
|
|
13
|
+
* Changes closer than twice this distance end up in the same region.
|
|
14
|
+
*/
|
|
15
|
+
export const CHANGED_REGION_MARGIN = 16
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Finds the areas of an image that changed.
|
|
19
|
+
*
|
|
20
|
+
* The changed-pixel mask grows by `margin` pixels in every direction,
|
|
21
|
+
* and each connected area becomes one box.
|
|
22
|
+
* Boxes that overlap are merged.
|
|
23
|
+
*
|
|
24
|
+
* @param mask One byte per pixel; non-zero marks a changed pixel.
|
|
25
|
+
* @returns The boxes, sorted top to bottom, then left to right.
|
|
26
|
+
*/
|
|
27
|
+
export function findChangedRegions(
|
|
28
|
+
mask: ArrayLike<number>,
|
|
29
|
+
width: number,
|
|
30
|
+
height: number,
|
|
31
|
+
margin = CHANGED_REGION_MARGIN,
|
|
32
|
+
): ChangedRegion[] {
|
|
33
|
+
const grown = growMask(mask, width, height, margin)
|
|
34
|
+
return mergeOverlappingRegions(labelRegions(grown, width, height))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Grows the mask by `margin` pixels in every direction (a square dilation),
|
|
39
|
+
* in two passes so the cost does not depend on the margin.
|
|
40
|
+
*/
|
|
41
|
+
function growMask(mask: ArrayLike<number>, width: number, height: number, margin: number) {
|
|
42
|
+
const horizontal = new Uint8Array(width * height)
|
|
43
|
+
const rowCounts = new Int32Array(width + 1)
|
|
44
|
+
for (let y = 0; y < height; y++) {
|
|
45
|
+
const row = y * width
|
|
46
|
+
for (let x = 0; x < width; x++) rowCounts[x + 1] = rowCounts[x]! + (mask[row + x] ? 1 : 0)
|
|
47
|
+
if (rowCounts[width] === 0) continue
|
|
48
|
+
for (let x = 0; x < width; x++) {
|
|
49
|
+
const from = Math.max(0, x - margin)
|
|
50
|
+
const to = Math.min(width, x + margin + 1)
|
|
51
|
+
if (rowCounts[to]! - rowCounts[from]! > 0) horizontal[row + x] = 1
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const grown = new Uint8Array(width * height)
|
|
56
|
+
const columnCounts = new Int32Array(height + 1)
|
|
57
|
+
for (let x = 0; x < width; x++) {
|
|
58
|
+
for (let y = 0; y < height; y++) columnCounts[y + 1] = columnCounts[y]! + horizontal[y * width + x]!
|
|
59
|
+
if (columnCounts[height] === 0) continue
|
|
60
|
+
for (let y = 0; y < height; y++) {
|
|
61
|
+
const from = Math.max(0, y - margin)
|
|
62
|
+
const to = Math.min(height, y + margin + 1)
|
|
63
|
+
if (columnCounts[to]! - columnCounts[from]! > 0) grown[y * width + x] = 1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return grown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the bounding box of each 4-connected area of the mask.
|
|
71
|
+
*/
|
|
72
|
+
function labelRegions(mask: Uint8Array, width: number, height: number) {
|
|
73
|
+
const visited = new Uint8Array(width * height)
|
|
74
|
+
const stack = new Int32Array(width * height)
|
|
75
|
+
const regions: ChangedRegion[] = []
|
|
76
|
+
for (let start = 0; start < mask.length; start++) {
|
|
77
|
+
if (!mask[start] || visited[start]) continue
|
|
78
|
+
let minX = width
|
|
79
|
+
let minY = height
|
|
80
|
+
let maxX = -1
|
|
81
|
+
let maxY = -1
|
|
82
|
+
let top = 0
|
|
83
|
+
stack[top++] = start
|
|
84
|
+
visited[start] = 1
|
|
85
|
+
while (top > 0) {
|
|
86
|
+
const i = stack[--top]!
|
|
87
|
+
const x = i % width
|
|
88
|
+
const y = (i - x) / width
|
|
89
|
+
if (x < minX) minX = x
|
|
90
|
+
if (x > maxX) maxX = x
|
|
91
|
+
if (y < minY) minY = y
|
|
92
|
+
if (y > maxY) maxY = y
|
|
93
|
+
if (x > 0 && mask[i - 1] && !visited[i - 1]) {
|
|
94
|
+
visited[i - 1] = 1
|
|
95
|
+
stack[top++] = i - 1
|
|
96
|
+
}
|
|
97
|
+
if (x < width - 1 && mask[i + 1] && !visited[i + 1]) {
|
|
98
|
+
visited[i + 1] = 1
|
|
99
|
+
stack[top++] = i + 1
|
|
100
|
+
}
|
|
101
|
+
if (y > 0 && mask[i - width] && !visited[i - width]) {
|
|
102
|
+
visited[i - width] = 1
|
|
103
|
+
stack[top++] = i - width
|
|
104
|
+
}
|
|
105
|
+
if (y < height - 1 && mask[i + width] && !visited[i + width]) {
|
|
106
|
+
visited[i + width] = 1
|
|
107
|
+
stack[top++] = i + width
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
regions.push({ x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 })
|
|
111
|
+
}
|
|
112
|
+
return regions
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merges boxes that overlap until no two boxes overlap.
|
|
117
|
+
*
|
|
118
|
+
* @returns The boxes, sorted top to bottom, then left to right.
|
|
119
|
+
*/
|
|
120
|
+
export function mergeOverlappingRegions(regions: ChangedRegion[]): ChangedRegion[] {
|
|
121
|
+
const result = regions.map((r) => ({ ...r }))
|
|
122
|
+
let merged = true
|
|
123
|
+
while (merged) {
|
|
124
|
+
merged = false
|
|
125
|
+
for (let i = 0; i < result.length; i++) {
|
|
126
|
+
for (let j = i + 1; j < result.length; j++) {
|
|
127
|
+
const a = result[i]!
|
|
128
|
+
const b = result[j]!
|
|
129
|
+
if (!overlaps(a, b)) continue
|
|
130
|
+
const x = Math.min(a.x, b.x)
|
|
131
|
+
const y = Math.min(a.y, b.y)
|
|
132
|
+
result[i] = {
|
|
133
|
+
x,
|
|
134
|
+
y,
|
|
135
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
136
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
|
137
|
+
}
|
|
138
|
+
result.splice(j, 1)
|
|
139
|
+
j = i
|
|
140
|
+
merged = true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function overlaps(a: ChangedRegion, b: ChangedRegion) {
|
|
148
|
+
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Describes the regions for a failure message,
|
|
153
|
+
* e.g. `2 regions: 106x37 at (0,0), 40x20 at (300,512)`.
|
|
154
|
+
*/
|
|
155
|
+
export function formatChangedRegions(regions: ChangedRegion[], maxListed = 5) {
|
|
156
|
+
if (regions.length === 0) return 'no region found'
|
|
157
|
+
const listed = regions
|
|
158
|
+
.slice(0, maxListed)
|
|
159
|
+
.map((r) => `${r.width}x${r.height} at (${r.x},${r.y})`)
|
|
160
|
+
.join(', ')
|
|
161
|
+
const more = regions.length > maxListed ? `, and ${regions.length - maxListed} more` : ''
|
|
162
|
+
return `${regions.length} ${regions.length === 1 ? 'region' : 'regions'}: ${listed}${more}`
|
|
163
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pixelmatch from 'pixelmatch'
|
|
2
2
|
import { type Options, ssim } from 'ssim.js'
|
|
3
3
|
import { convertThresholdUnit } from './convert_threshold_unit.ts'
|
|
4
|
-
import type { ComparisonMethod, ImageSnapshotCompareOptions } from './types.ts'
|
|
4
|
+
import type { ComparisonMethod, ImageSnapshotCompareOptions, PixelDiffOptions } from './types.ts'
|
|
5
5
|
|
|
6
6
|
export function compareImage<M extends ComparisonMethod>(
|
|
7
7
|
img1: Uint8ClampedArray | Buffer,
|
|
@@ -26,6 +26,38 @@ export function compareImage<M extends ComparisonMethod>(
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A pixel counts as changed in the SSIM comparison when its SSIM value is below this.
|
|
31
|
+
*/
|
|
32
|
+
export const SSIM_CHANGED_THRESHOLD = 0.95
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns one byte per pixel, non-zero where the pixel changed.
|
|
36
|
+
*
|
|
37
|
+
* The pixel method runs pixelmatch again in mask mode, so anti-aliased pixels are not included.
|
|
38
|
+
* The SSIM method reads the diff written by {@link compareImage} and applies {@link SSIM_CHANGED_THRESHOLD}.
|
|
39
|
+
*/
|
|
40
|
+
export function getChangedMask<M extends ComparisonMethod>(
|
|
41
|
+
img1: Uint8ClampedArray | Buffer,
|
|
42
|
+
img2: Uint8ClampedArray | Buffer,
|
|
43
|
+
diff: Uint8ClampedArray | Buffer,
|
|
44
|
+
width: number,
|
|
45
|
+
height: number,
|
|
46
|
+
options: ImageSnapshotCompareOptions<M> = {} as any,
|
|
47
|
+
) {
|
|
48
|
+
const mask = new Uint8Array(width * height)
|
|
49
|
+
if (options.comparisonMethod === 'ssim') {
|
|
50
|
+
// `compareWithSsim()` writes `1 - ssim` to the alpha channel
|
|
51
|
+
const minAlpha = 0xff * (1 - SSIM_CHANGED_THRESHOLD)
|
|
52
|
+
for (let i = 0; i < mask.length; i++) if (diff[i * 4 + 3]! > minAlpha) mask[i] = 1
|
|
53
|
+
return mask
|
|
54
|
+
}
|
|
55
|
+
const output = new Uint8ClampedArray(width * height * 4)
|
|
56
|
+
pixelmatch(img1, img2, output, width, height, { ...(options.diffOptions as PixelDiffOptions), diffMask: true })
|
|
57
|
+
for (let i = 0; i < mask.length; i++) if (output[i * 4 + 3]) mask[i] = 1
|
|
58
|
+
return mask
|
|
59
|
+
}
|
|
60
|
+
|
|
29
61
|
/**
|
|
30
62
|
* @author This code is based on `jest-image-snapshot` implementation.
|
|
31
63
|
*/
|
package/src/shared/constants.ts
CHANGED
|
@@ -3,3 +3,5 @@ export const SNAPSHOT_ROOT_DIR = '__vis__'
|
|
|
3
3
|
export const BASELINE_DIR = '__baselines__'
|
|
4
4
|
export const RESULT_DIR = '__results__'
|
|
5
5
|
export const DIFF_DIR = '__diffs__'
|
|
6
|
+
export const REVIEW_IMAGE_SUFFIX = '.review.png'
|
|
7
|
+
export const CHANGED_REGIONS_SUFFIX = '.regions.json'
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ChangedRegion } from './changed_regions.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Raw RGBA pixels, compatible with `ImageData`.
|
|
5
|
+
*/
|
|
6
|
+
export type PixelImage = {
|
|
7
|
+
data: Uint8ClampedArray<ArrayBuffer>
|
|
8
|
+
width: number
|
|
9
|
+
height: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The longest edge of the review image, in pixels.
|
|
14
|
+
*/
|
|
15
|
+
export const REVIEW_IMAGE_MAX_EDGE = 2576
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The review image is skipped when there are more regions than this.
|
|
19
|
+
*/
|
|
20
|
+
export const REVIEW_IMAGE_MAX_REGIONS = 5
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The review image is skipped when the regions cover more than this share of the image.
|
|
24
|
+
*/
|
|
25
|
+
export const REVIEW_IMAGE_MAX_COVERAGE = 0.5
|
|
26
|
+
|
|
27
|
+
const CROP_GAP = 4
|
|
28
|
+
const REGION_GAP = 16
|
|
29
|
+
const GAP_COLOR = [128, 128, 128, 255] as const
|
|
30
|
+
const CHANGED_COLOR = [255, 0, 0, 255] as const
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns why a review image does not help, or `undefined` when it does.
|
|
34
|
+
*/
|
|
35
|
+
export function getReviewImageSkipReason({
|
|
36
|
+
baseline,
|
|
37
|
+
result,
|
|
38
|
+
regions,
|
|
39
|
+
}: {
|
|
40
|
+
baseline: { width: number; height: number }
|
|
41
|
+
result: { width: number; height: number }
|
|
42
|
+
regions: ChangedRegion[]
|
|
43
|
+
}) {
|
|
44
|
+
if (baseline.width !== result.width || baseline.height !== result.height) return 'the image size changed'
|
|
45
|
+
if (regions.length === 0) return 'no changed region found'
|
|
46
|
+
if (regions.length > REVIEW_IMAGE_MAX_REGIONS)
|
|
47
|
+
return `${regions.length} regions, more than ${REVIEW_IMAGE_MAX_REGIONS}`
|
|
48
|
+
const area = regions.reduce((sum, r) => sum + r.width * r.height, 0)
|
|
49
|
+
const coverage = area / (baseline.width * baseline.height)
|
|
50
|
+
if (coverage > REVIEW_IMAGE_MAX_COVERAGE) return `the regions cover ${Math.round(coverage * 100)}% of the image`
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates one image that shows, for each region, the baseline crop, the result crop, and the diff crop.
|
|
56
|
+
*
|
|
57
|
+
* A wide region places its crops top to bottom, a narrow one left to right.
|
|
58
|
+
* Regions are placed top to bottom.
|
|
59
|
+
* The diff crop shows the baseline in grayscale at full contrast with the changed pixels in red.
|
|
60
|
+
* The image is scaled down to fit {@link REVIEW_IMAGE_MAX_EDGE}.
|
|
61
|
+
*
|
|
62
|
+
* @param mask One byte per pixel of the baseline; non-zero marks a changed pixel.
|
|
63
|
+
*/
|
|
64
|
+
export function createReviewImage(
|
|
65
|
+
baseline: PixelImage,
|
|
66
|
+
result: PixelImage,
|
|
67
|
+
mask: ArrayLike<number>,
|
|
68
|
+
regions: ChangedRegion[],
|
|
69
|
+
): PixelImage {
|
|
70
|
+
const panels = regions.map((region) => {
|
|
71
|
+
const crops = [crop(baseline, region), crop(result, region), createDiffCrop(baseline, mask, region)]
|
|
72
|
+
return region.width > region.height ? stack(crops, 'vertical', CROP_GAP) : stack(crops, 'horizontal', CROP_GAP)
|
|
73
|
+
})
|
|
74
|
+
return fitWithin(stack(panels, 'vertical', REGION_GAP), REVIEW_IMAGE_MAX_EDGE)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function crop(image: PixelImage, { x, y, width, height }: ChangedRegion): PixelImage {
|
|
78
|
+
const data = new Uint8ClampedArray(width * height * 4)
|
|
79
|
+
for (let row = 0; row < height; row++) {
|
|
80
|
+
const start = ((y + row) * image.width + x) * 4
|
|
81
|
+
data.set(image.data.subarray(start, start + width * 4), row * width * 4)
|
|
82
|
+
}
|
|
83
|
+
return { data, width, height }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createDiffCrop(
|
|
87
|
+
image: PixelImage,
|
|
88
|
+
mask: ArrayLike<number>,
|
|
89
|
+
{ x, y, width, height }: ChangedRegion,
|
|
90
|
+
): PixelImage {
|
|
91
|
+
const data = new Uint8ClampedArray(width * height * 4)
|
|
92
|
+
for (let row = 0; row < height; row++) {
|
|
93
|
+
for (let col = 0; col < width; col++) {
|
|
94
|
+
const i = (y + row) * image.width + x + col
|
|
95
|
+
const pos = (row * width + col) * 4
|
|
96
|
+
if (mask[i]) {
|
|
97
|
+
data.set(CHANGED_COLOR, pos)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const src = i * 4
|
|
101
|
+
const r = image.data[src]!
|
|
102
|
+
const g = image.data[src + 1]!
|
|
103
|
+
const b = image.data[src + 2]!
|
|
104
|
+
const a = image.data[src + 3]!
|
|
105
|
+
const luma = 0.29889531 * r + 0.58662247 * g + 0.11448223 * b
|
|
106
|
+
// blend with white by the pixel's own alpha only, so the baseline is not faded
|
|
107
|
+
const gray = 255 + ((luma - 255) * a) / 255
|
|
108
|
+
data[pos] = gray
|
|
109
|
+
data[pos + 1] = gray
|
|
110
|
+
data[pos + 2] = gray
|
|
111
|
+
data[pos + 3] = 255
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { data, width, height }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stack(images: PixelImage[], direction: 'horizontal' | 'vertical', gap: number): PixelImage {
|
|
118
|
+
const horizontal = direction === 'horizontal'
|
|
119
|
+
const gaps = gap * (images.length - 1)
|
|
120
|
+
const width = horizontal
|
|
121
|
+
? images.reduce((sum, image) => sum + image.width, gaps)
|
|
122
|
+
: Math.max(...images.map((image) => image.width))
|
|
123
|
+
const height = horizontal
|
|
124
|
+
? Math.max(...images.map((image) => image.height))
|
|
125
|
+
: images.reduce((sum, image) => sum + image.height, gaps)
|
|
126
|
+
const data = new Uint8ClampedArray(width * height * 4)
|
|
127
|
+
for (let i = 0; i < data.length; i += 4) data.set(GAP_COLOR, i)
|
|
128
|
+
|
|
129
|
+
let offset = 0
|
|
130
|
+
for (const image of images) {
|
|
131
|
+
const x = horizontal ? offset : 0
|
|
132
|
+
const y = horizontal ? 0 : offset
|
|
133
|
+
for (let row = 0; row < image.height; row++) {
|
|
134
|
+
data.set(image.data.subarray(row * image.width * 4, (row + 1) * image.width * 4), ((y + row) * width + x) * 4)
|
|
135
|
+
}
|
|
136
|
+
offset += (horizontal ? image.width : image.height) + gap
|
|
137
|
+
}
|
|
138
|
+
return { data, width, height }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Scales the image down, averaging the pixels each target pixel covers,
|
|
143
|
+
* so its longest edge is at most `maxEdge`.
|
|
144
|
+
*/
|
|
145
|
+
function fitWithin(image: PixelImage, maxEdge: number): PixelImage {
|
|
146
|
+
const scale = maxEdge / Math.max(image.width, image.height)
|
|
147
|
+
if (scale >= 1) return image
|
|
148
|
+
|
|
149
|
+
const width = Math.max(1, Math.round(image.width * scale))
|
|
150
|
+
const height = Math.max(1, Math.round(image.height * scale))
|
|
151
|
+
const data = new Uint8ClampedArray(width * height * 4)
|
|
152
|
+
for (let y = 0; y < height; y++) {
|
|
153
|
+
const fromY = Math.floor((y * image.height) / height)
|
|
154
|
+
const toY = Math.max(fromY + 1, Math.floor(((y + 1) * image.height) / height))
|
|
155
|
+
for (let x = 0; x < width; x++) {
|
|
156
|
+
const fromX = Math.floor((x * image.width) / width)
|
|
157
|
+
const toX = Math.max(fromX + 1, Math.floor(((x + 1) * image.width) / width))
|
|
158
|
+
let r = 0
|
|
159
|
+
let g = 0
|
|
160
|
+
let b = 0
|
|
161
|
+
let a = 0
|
|
162
|
+
for (let sy = fromY; sy < toY; sy++) {
|
|
163
|
+
for (let sx = fromX; sx < toX; sx++) {
|
|
164
|
+
const src = (sy * image.width + sx) * 4
|
|
165
|
+
r += image.data[src]!
|
|
166
|
+
g += image.data[src + 1]!
|
|
167
|
+
b += image.data[src + 2]!
|
|
168
|
+
a += image.data[src + 3]!
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const count = (toY - fromY) * (toX - fromX)
|
|
172
|
+
const pos = (y * width + x) * 4
|
|
173
|
+
data[pos] = r / count
|
|
174
|
+
data[pos + 1] = g / count
|
|
175
|
+
data[pos + 2] = b / count
|
|
176
|
+
data[pos + 3] = a / count
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { data, width, height }
|
|
180
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -21,6 +21,22 @@ export interface ImageSnapshotKeyOptions {
|
|
|
21
21
|
snapshotKey?: string | undefined
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface ReviewImageOptions {
|
|
25
|
+
/**
|
|
26
|
+
* When the snapshot mismatches, write a review image next to the diff.
|
|
27
|
+
*
|
|
28
|
+
* The review image places the baseline, result, and diff crops of each changed region side by side,
|
|
29
|
+
* so a person or an agent can see what changed without opening the full images.
|
|
30
|
+
* It is skipped when cropping does not help:
|
|
31
|
+
* the image size changed, there are many regions, or the regions cover most of the image.
|
|
32
|
+
*
|
|
33
|
+
* The changed regions are written as JSON next to the diff either way.
|
|
34
|
+
*
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
reviewImage?: boolean | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
export type ComparisonMethod = 'pixel' | 'ssim'
|
|
25
41
|
|
|
26
42
|
export type FailureThresholdOptions = {
|
|
@@ -90,7 +106,8 @@ export type ImageSnapshotSubjectOptions = {
|
|
|
90
106
|
}
|
|
91
107
|
export type ToMatchImageSnapshotOptions<M extends ComparisonMethod = 'pixel'> = ImageSnapshotTimeoutOptions &
|
|
92
108
|
ImageSnapshotKeyOptions &
|
|
93
|
-
ImageSnapshotCompareOptions<M> &
|
|
109
|
+
ImageSnapshotCompareOptions<M> &
|
|
110
|
+
ReviewImageOptions & {
|
|
94
111
|
/**
|
|
95
112
|
* Expect the matcher to fail.
|
|
96
113
|
* If it passes, it will throw an error with details.
|
|
@@ -152,11 +169,13 @@ export type ImageSnapshotComparisonInfo = {
|
|
|
152
169
|
diff?: string | undefined
|
|
153
170
|
} & ImageSnapshotTimeoutOptions &
|
|
154
171
|
FailureThresholdOptions &
|
|
172
|
+
ReviewImageOptions &
|
|
155
173
|
(SsimComparisonOptions | PixelComparisonOptions)
|
|
156
174
|
|
|
157
175
|
export type MatchImageSnapshotOptions = ImageSnapshotTimeoutOptions &
|
|
158
176
|
ImageSnapshotCompareOptions<any> &
|
|
159
|
-
ImageSnapshotKeyOptions &
|
|
177
|
+
ImageSnapshotKeyOptions &
|
|
178
|
+
ReviewImageOptions & {
|
|
160
179
|
/**
|
|
161
180
|
* When `true`, write a missing baseline on first run (see {@link ToMatchImageSnapshotOptions} `createMissingBaseline`).
|
|
162
181
|
*/
|