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.
Files changed (53) 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/dist/client/external/browser/image_data.mjs +18 -16
  5. package/dist/client/external/browser/image_data.mjs.map +1 -1
  6. package/dist/client/snapshot/compare_image_snapshot.mjs +31 -2
  7. package/dist/client/snapshot/compare_image_snapshot.mjs.map +1 -1
  8. package/dist/config/types.d.mts +2 -2
  9. package/dist/config/types.d.mts.map +1 -1
  10. package/dist/exports/client-api.d.mts +3 -3
  11. package/dist/exports/client-api.mjs +2 -2
  12. package/dist/exports/server-utils.d.mts +2 -2
  13. package/dist/exports/server-utils.mjs +2 -2
  14. package/dist/index.d.mts +2 -2
  15. package/dist/server/testing/stubBrowserCommandContext.mjs +1 -1
  16. package/dist/server/testing/stubBrowserCommandContext.mjs.map +1 -1
  17. package/dist/server/testing/stubUserConfig.mjs +1 -1
  18. package/dist/server/testing/stubUserConfig.mjs.map +1 -1
  19. package/dist/server/vis_options.d.mts +4 -0
  20. package/dist/server/vis_server_context.logic.mjs +1 -1
  21. package/dist/server/vis_server_context.logic.mjs.map +1 -1
  22. package/dist/shared/changed_regions.mjs +143 -0
  23. package/dist/shared/changed_regions.mjs.map +1 -0
  24. package/dist/shared/compare_image.mjs +26 -1
  25. package/dist/shared/compare_image.mjs.map +1 -1
  26. package/dist/shared/constants.d.mts +3 -1
  27. package/dist/shared/constants.d.mts.map +1 -1
  28. package/dist/shared/constants.mjs +3 -1
  29. package/dist/shared/constants.mjs.map +1 -1
  30. package/dist/shared/review_image.mjs +160 -0
  31. package/dist/shared/review_image.mjs.map +1 -0
  32. package/dist/shared/types.d.mts +19 -4
  33. package/dist/shared/types.d.mts.map +1 -1
  34. package/llms.txt +54 -0
  35. package/package.json +10 -4
  36. package/plugin.json +24 -0
  37. package/skills/review-image-snapshot-failures/README.md +18 -0
  38. package/skills/review-image-snapshot-failures/SKILL.md +37 -0
  39. package/src/client/expect/to_match_image_snapshot.mdx +33 -0
  40. package/src/client/external/browser/image_data.ts +20 -19
  41. package/src/client/snapshot/compare_image_snapshot.ts +40 -2
  42. package/src/config/types.ts +3 -1
  43. package/src/config/vis.mdx +21 -0
  44. package/src/exports/server-utils.ts +7 -1
  45. package/src/overview.mdx +2 -0
  46. package/src/server/testing/stubBrowserCommandContext.ts +1 -1
  47. package/src/server/testing/stubUserConfig.ts +1 -1
  48. package/src/server/vis_server_context.logic.ts +1 -0
  49. package/src/shared/changed_regions.ts +163 -0
  50. package/src/shared/compare_image.ts +33 -1
  51. package/src/shared/constants.ts +2 -0
  52. package/src/shared/review_image.ts +180 -0
  53. 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' })
@@ -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
  *
@@ -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 { BASELINE_DIR, DIFF_DIR, SNAPSHOT_ROOT_DIR } from '../shared/constants.ts'
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>({})
@@ -1,4 +1,4 @@
1
- import { stub } from 'type-plus'
1
+ import { stub } from 'type-plus/testing'
2
2
  import type { ViteUserConfig } from 'vitest/config'
3
3
 
4
4
  export const stubUserConfig = stub.build<ViteUserConfig>((stub) => ({
@@ -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
  */