vitest-plugin-vis 5.1.8 → 5.2.1

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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/.codex-plugin/plugin.json +18 -0
  3. package/.cursor-plugin/plugin.json +18 -0
  4. package/CHANGELOG.md +745 -0
  5. package/dist/client/external/browser/image_data.mjs +18 -16
  6. package/dist/client/external/browser/image_data.mjs.map +1 -1
  7. package/dist/client/snapshot/compare_image_snapshot.mjs +31 -2
  8. package/dist/client/snapshot/compare_image_snapshot.mjs.map +1 -1
  9. package/dist/config/types.d.mts +2 -2
  10. package/dist/config/types.d.mts.map +1 -1
  11. package/dist/exports/client-api.d.mts +3 -3
  12. package/dist/exports/client-api.mjs +2 -2
  13. package/dist/exports/server-utils.d.mts +2 -2
  14. package/dist/exports/server-utils.mjs +2 -2
  15. package/dist/index.d.mts +2 -2
  16. package/dist/server/vis_options.d.mts +4 -0
  17. package/dist/server/vis_server_context.logic.mjs +1 -1
  18. package/dist/server/vis_server_context.logic.mjs.map +1 -1
  19. package/dist/shared/changed_regions.mjs +143 -0
  20. package/dist/shared/changed_regions.mjs.map +1 -0
  21. package/dist/shared/compare_image.mjs +26 -1
  22. package/dist/shared/compare_image.mjs.map +1 -1
  23. package/dist/shared/constants.d.mts +3 -1
  24. package/dist/shared/constants.d.mts.map +1 -1
  25. package/dist/shared/constants.mjs +3 -1
  26. package/dist/shared/constants.mjs.map +1 -1
  27. package/dist/shared/review_image.mjs +160 -0
  28. package/dist/shared/review_image.mjs.map +1 -0
  29. package/dist/shared/types.d.mts +19 -4
  30. package/dist/shared/types.d.mts.map +1 -1
  31. package/llms.txt +54 -0
  32. package/package.json +10 -3
  33. package/plugin.json +24 -0
  34. package/skills/review-image-snapshot-failures/README.md +18 -0
  35. package/skills/review-image-snapshot-failures/SKILL.md +37 -0
  36. package/src/client/expect/to_match_image_snapshot.mdx +33 -0
  37. package/src/client/external/browser/image_data.ts +20 -19
  38. package/src/client/snapshot/compare_image_snapshot.ts +40 -2
  39. package/src/config/types.ts +3 -1
  40. package/src/config/vis.mdx +21 -0
  41. package/src/exports/server-utils.ts +7 -1
  42. package/src/overview.mdx +2 -0
  43. package/src/server/vis_server_context.logic.ts +1 -0
  44. package/src/shared/changed_regions.ts +163 -0
  45. package/src/shared/compare_image.ts +33 -1
  46. package/src/shared/constants.ts +2 -0
  47. package/src/shared/review_image.ts +180 -0
  48. package/src/shared/types.ts +21 -2
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
 
@@ -69,6 +69,7 @@ export function createVisServerContext() {
69
69
  'diffOptions',
70
70
  'failureThreshold',
71
71
  'failureThresholdType',
72
+ 'reviewImage',
72
73
  'timeout',
73
74
  ),
74
75
  baselinePath,
@@ -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
  */
@@ -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
+ }
@@ -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
  */