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
@@ -0,0 +1,160 @@
1
+ //#region src/shared/review_image.ts
2
+ /**
3
+ * The longest edge of the review image, in pixels.
4
+ */
5
+ const REVIEW_IMAGE_MAX_EDGE = 2576;
6
+ /**
7
+ * The review image is skipped when there are more regions than this.
8
+ */
9
+ const REVIEW_IMAGE_MAX_REGIONS = 5;
10
+ /**
11
+ * The review image is skipped when the regions cover more than this share of the image.
12
+ */
13
+ const REVIEW_IMAGE_MAX_COVERAGE = .5;
14
+ const CROP_GAP = 4;
15
+ const REGION_GAP = 16;
16
+ const GAP_COLOR = [
17
+ 128,
18
+ 128,
19
+ 128,
20
+ 255
21
+ ];
22
+ const CHANGED_COLOR = [
23
+ 255,
24
+ 0,
25
+ 0,
26
+ 255
27
+ ];
28
+ /**
29
+ * Returns why a review image does not help, or `undefined` when it does.
30
+ */
31
+ function getReviewImageSkipReason({ baseline, result, regions }) {
32
+ if (baseline.width !== result.width || baseline.height !== result.height) return "the image size changed";
33
+ if (regions.length === 0) return "no changed region found";
34
+ if (regions.length > REVIEW_IMAGE_MAX_REGIONS) return `${regions.length} regions, more than ${REVIEW_IMAGE_MAX_REGIONS}`;
35
+ const coverage = regions.reduce((sum, r) => sum + r.width * r.height, 0) / (baseline.width * baseline.height);
36
+ if (coverage > REVIEW_IMAGE_MAX_COVERAGE) return `the regions cover ${Math.round(coverage * 100)}% of the image`;
37
+ }
38
+ /**
39
+ * Creates one image that shows, for each region, the baseline crop, the result crop, and the diff crop.
40
+ *
41
+ * A wide region places its crops top to bottom, a narrow one left to right.
42
+ * Regions are placed top to bottom.
43
+ * The diff crop shows the baseline in grayscale at full contrast with the changed pixels in red.
44
+ * The image is scaled down to fit {@link REVIEW_IMAGE_MAX_EDGE}.
45
+ *
46
+ * @param mask One byte per pixel of the baseline; non-zero marks a changed pixel.
47
+ */
48
+ function createReviewImage(baseline, result, mask, regions) {
49
+ return fitWithin(stack(regions.map((region) => {
50
+ const crops = [
51
+ crop(baseline, region),
52
+ crop(result, region),
53
+ createDiffCrop(baseline, mask, region)
54
+ ];
55
+ return region.width > region.height ? stack(crops, "vertical", CROP_GAP) : stack(crops, "horizontal", CROP_GAP);
56
+ }), "vertical", REGION_GAP), REVIEW_IMAGE_MAX_EDGE);
57
+ }
58
+ function crop(image, { x, y, width, height }) {
59
+ const data = new Uint8ClampedArray(width * height * 4);
60
+ for (let row = 0; row < height; row++) {
61
+ const start = ((y + row) * image.width + x) * 4;
62
+ data.set(image.data.subarray(start, start + width * 4), row * width * 4);
63
+ }
64
+ return {
65
+ data,
66
+ width,
67
+ height
68
+ };
69
+ }
70
+ function createDiffCrop(image, mask, { x, y, width, height }) {
71
+ const data = new Uint8ClampedArray(width * height * 4);
72
+ for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
73
+ const i = (y + row) * image.width + x + col;
74
+ const pos = (row * width + col) * 4;
75
+ if (mask[i]) {
76
+ data.set(CHANGED_COLOR, pos);
77
+ continue;
78
+ }
79
+ const src = i * 4;
80
+ const r = image.data[src];
81
+ const g = image.data[src + 1];
82
+ const b = image.data[src + 2];
83
+ const a = image.data[src + 3];
84
+ const gray = 255 + (.29889531 * r + .58662247 * g + .11448223 * b - 255) * a / 255;
85
+ data[pos] = gray;
86
+ data[pos + 1] = gray;
87
+ data[pos + 2] = gray;
88
+ data[pos + 3] = 255;
89
+ }
90
+ return {
91
+ data,
92
+ width,
93
+ height
94
+ };
95
+ }
96
+ function stack(images, direction, gap) {
97
+ const horizontal = direction === "horizontal";
98
+ const gaps = gap * (images.length - 1);
99
+ const width = horizontal ? images.reduce((sum, image) => sum + image.width, gaps) : Math.max(...images.map((image) => image.width));
100
+ const height = horizontal ? Math.max(...images.map((image) => image.height)) : images.reduce((sum, image) => sum + image.height, gaps);
101
+ const data = new Uint8ClampedArray(width * height * 4);
102
+ for (let i = 0; i < data.length; i += 4) data.set(GAP_COLOR, i);
103
+ let offset = 0;
104
+ for (const image of images) {
105
+ const x = horizontal ? offset : 0;
106
+ const y = horizontal ? 0 : offset;
107
+ for (let row = 0; row < image.height; row++) data.set(image.data.subarray(row * image.width * 4, (row + 1) * image.width * 4), ((y + row) * width + x) * 4);
108
+ offset += (horizontal ? image.width : image.height) + gap;
109
+ }
110
+ return {
111
+ data,
112
+ width,
113
+ height
114
+ };
115
+ }
116
+ /**
117
+ * Scales the image down, averaging the pixels each target pixel covers,
118
+ * so its longest edge is at most `maxEdge`.
119
+ */
120
+ function fitWithin(image, maxEdge) {
121
+ const scale = maxEdge / Math.max(image.width, image.height);
122
+ if (scale >= 1) return image;
123
+ const width = Math.max(1, Math.round(image.width * scale));
124
+ const height = Math.max(1, Math.round(image.height * scale));
125
+ const data = new Uint8ClampedArray(width * height * 4);
126
+ for (let y = 0; y < height; y++) {
127
+ const fromY = Math.floor(y * image.height / height);
128
+ const toY = Math.max(fromY + 1, Math.floor((y + 1) * image.height / height));
129
+ for (let x = 0; x < width; x++) {
130
+ const fromX = Math.floor(x * image.width / width);
131
+ const toX = Math.max(fromX + 1, Math.floor((x + 1) * image.width / width));
132
+ let r = 0;
133
+ let g = 0;
134
+ let b = 0;
135
+ let a = 0;
136
+ for (let sy = fromY; sy < toY; sy++) for (let sx = fromX; sx < toX; sx++) {
137
+ const src = (sy * image.width + sx) * 4;
138
+ r += image.data[src];
139
+ g += image.data[src + 1];
140
+ b += image.data[src + 2];
141
+ a += image.data[src + 3];
142
+ }
143
+ const count = (toY - fromY) * (toX - fromX);
144
+ const pos = (y * width + x) * 4;
145
+ data[pos] = r / count;
146
+ data[pos + 1] = g / count;
147
+ data[pos + 2] = b / count;
148
+ data[pos + 3] = a / count;
149
+ }
150
+ }
151
+ return {
152
+ data,
153
+ width,
154
+ height
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ export { createReviewImage, getReviewImageSkipReason };
160
+ //# sourceMappingURL=review_image.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review_image.mjs","names":[],"sources":["../../src/shared/review_image.ts"],"sourcesContent":["import type { ChangedRegion } from './changed_regions.ts'\n\n/**\n * Raw RGBA pixels, compatible with `ImageData`.\n */\nexport type PixelImage = {\n\tdata: Uint8ClampedArray<ArrayBuffer>\n\twidth: number\n\theight: number\n}\n\n/**\n * The longest edge of the review image, in pixels.\n */\nexport const REVIEW_IMAGE_MAX_EDGE = 2576\n\n/**\n * The review image is skipped when there are more regions than this.\n */\nexport const REVIEW_IMAGE_MAX_REGIONS = 5\n\n/**\n * The review image is skipped when the regions cover more than this share of the image.\n */\nexport const REVIEW_IMAGE_MAX_COVERAGE = 0.5\n\nconst CROP_GAP = 4\nconst REGION_GAP = 16\nconst GAP_COLOR = [128, 128, 128, 255] as const\nconst CHANGED_COLOR = [255, 0, 0, 255] as const\n\n/**\n * Returns why a review image does not help, or `undefined` when it does.\n */\nexport function getReviewImageSkipReason({\n\tbaseline,\n\tresult,\n\tregions,\n}: {\n\tbaseline: { width: number; height: number }\n\tresult: { width: number; height: number }\n\tregions: ChangedRegion[]\n}) {\n\tif (baseline.width !== result.width || baseline.height !== result.height) return 'the image size changed'\n\tif (regions.length === 0) return 'no changed region found'\n\tif (regions.length > REVIEW_IMAGE_MAX_REGIONS)\n\t\treturn `${regions.length} regions, more than ${REVIEW_IMAGE_MAX_REGIONS}`\n\tconst area = regions.reduce((sum, r) => sum + r.width * r.height, 0)\n\tconst coverage = area / (baseline.width * baseline.height)\n\tif (coverage > REVIEW_IMAGE_MAX_COVERAGE) return `the regions cover ${Math.round(coverage * 100)}% of the image`\n\treturn undefined\n}\n\n/**\n * Creates one image that shows, for each region, the baseline crop, the result crop, and the diff crop.\n *\n * A wide region places its crops top to bottom, a narrow one left to right.\n * Regions are placed top to bottom.\n * The diff crop shows the baseline in grayscale at full contrast with the changed pixels in red.\n * The image is scaled down to fit {@link REVIEW_IMAGE_MAX_EDGE}.\n *\n * @param mask One byte per pixel of the baseline; non-zero marks a changed pixel.\n */\nexport function createReviewImage(\n\tbaseline: PixelImage,\n\tresult: PixelImage,\n\tmask: ArrayLike<number>,\n\tregions: ChangedRegion[],\n): PixelImage {\n\tconst panels = regions.map((region) => {\n\t\tconst crops = [crop(baseline, region), crop(result, region), createDiffCrop(baseline, mask, region)]\n\t\treturn region.width > region.height ? stack(crops, 'vertical', CROP_GAP) : stack(crops, 'horizontal', CROP_GAP)\n\t})\n\treturn fitWithin(stack(panels, 'vertical', REGION_GAP), REVIEW_IMAGE_MAX_EDGE)\n}\n\nfunction crop(image: PixelImage, { x, y, width, height }: ChangedRegion): PixelImage {\n\tconst data = new Uint8ClampedArray(width * height * 4)\n\tfor (let row = 0; row < height; row++) {\n\t\tconst start = ((y + row) * image.width + x) * 4\n\t\tdata.set(image.data.subarray(start, start + width * 4), row * width * 4)\n\t}\n\treturn { data, width, height }\n}\n\nfunction createDiffCrop(\n\timage: PixelImage,\n\tmask: ArrayLike<number>,\n\t{ x, y, width, height }: ChangedRegion,\n): PixelImage {\n\tconst data = new Uint8ClampedArray(width * height * 4)\n\tfor (let row = 0; row < height; row++) {\n\t\tfor (let col = 0; col < width; col++) {\n\t\t\tconst i = (y + row) * image.width + x + col\n\t\t\tconst pos = (row * width + col) * 4\n\t\t\tif (mask[i]) {\n\t\t\t\tdata.set(CHANGED_COLOR, pos)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst src = i * 4\n\t\t\tconst r = image.data[src]!\n\t\t\tconst g = image.data[src + 1]!\n\t\t\tconst b = image.data[src + 2]!\n\t\t\tconst a = image.data[src + 3]!\n\t\t\tconst luma = 0.29889531 * r + 0.58662247 * g + 0.11448223 * b\n\t\t\t// blend with white by the pixel's own alpha only, so the baseline is not faded\n\t\t\tconst gray = 255 + ((luma - 255) * a) / 255\n\t\t\tdata[pos] = gray\n\t\t\tdata[pos + 1] = gray\n\t\t\tdata[pos + 2] = gray\n\t\t\tdata[pos + 3] = 255\n\t\t}\n\t}\n\treturn { data, width, height }\n}\n\nfunction stack(images: PixelImage[], direction: 'horizontal' | 'vertical', gap: number): PixelImage {\n\tconst horizontal = direction === 'horizontal'\n\tconst gaps = gap * (images.length - 1)\n\tconst width = horizontal\n\t\t? images.reduce((sum, image) => sum + image.width, gaps)\n\t\t: Math.max(...images.map((image) => image.width))\n\tconst height = horizontal\n\t\t? Math.max(...images.map((image) => image.height))\n\t\t: images.reduce((sum, image) => sum + image.height, gaps)\n\tconst data = new Uint8ClampedArray(width * height * 4)\n\tfor (let i = 0; i < data.length; i += 4) data.set(GAP_COLOR, i)\n\n\tlet offset = 0\n\tfor (const image of images) {\n\t\tconst x = horizontal ? offset : 0\n\t\tconst y = horizontal ? 0 : offset\n\t\tfor (let row = 0; row < image.height; row++) {\n\t\t\tdata.set(image.data.subarray(row * image.width * 4, (row + 1) * image.width * 4), ((y + row) * width + x) * 4)\n\t\t}\n\t\toffset += (horizontal ? image.width : image.height) + gap\n\t}\n\treturn { data, width, height }\n}\n\n/**\n * Scales the image down, averaging the pixels each target pixel covers,\n * so its longest edge is at most `maxEdge`.\n */\nfunction fitWithin(image: PixelImage, maxEdge: number): PixelImage {\n\tconst scale = maxEdge / Math.max(image.width, image.height)\n\tif (scale >= 1) return image\n\n\tconst width = Math.max(1, Math.round(image.width * scale))\n\tconst height = Math.max(1, Math.round(image.height * scale))\n\tconst data = new Uint8ClampedArray(width * height * 4)\n\tfor (let y = 0; y < height; y++) {\n\t\tconst fromY = Math.floor((y * image.height) / height)\n\t\tconst toY = Math.max(fromY + 1, Math.floor(((y + 1) * image.height) / height))\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst fromX = Math.floor((x * image.width) / width)\n\t\t\tconst toX = Math.max(fromX + 1, Math.floor(((x + 1) * image.width) / width))\n\t\t\tlet r = 0\n\t\t\tlet g = 0\n\t\t\tlet b = 0\n\t\t\tlet a = 0\n\t\t\tfor (let sy = fromY; sy < toY; sy++) {\n\t\t\t\tfor (let sx = fromX; sx < toX; sx++) {\n\t\t\t\t\tconst src = (sy * image.width + sx) * 4\n\t\t\t\t\tr += image.data[src]!\n\t\t\t\t\tg += image.data[src + 1]!\n\t\t\t\t\tb += image.data[src + 2]!\n\t\t\t\t\ta += image.data[src + 3]!\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst count = (toY - fromY) * (toX - fromX)\n\t\t\tconst pos = (y * width + x) * 4\n\t\t\tdata[pos] = r / count\n\t\t\tdata[pos + 1] = g / count\n\t\t\tdata[pos + 2] = b / count\n\t\t\tdata[pos + 3] = a / count\n\t\t}\n\t}\n\treturn { data, width, height }\n}\n"],"mappings":"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"}
@@ -19,6 +19,21 @@ interface ImageSnapshotKeyOptions {
19
19
  */
20
20
  snapshotKey?: string | undefined;
21
21
  }
22
+ interface ReviewImageOptions {
23
+ /**
24
+ * When the snapshot mismatches, write a review image next to the diff.
25
+ *
26
+ * The review image places the baseline, result, and diff crops of each changed region side by side,
27
+ * so a person or an agent can see what changed without opening the full images.
28
+ * It is skipped when cropping does not help:
29
+ * the image size changed, there are many regions, or the regions cover most of the image.
30
+ *
31
+ * The changed regions are written as JSON next to the diff either way.
32
+ *
33
+ * @default true
34
+ */
35
+ reviewImage?: boolean | undefined;
36
+ }
22
37
  type ComparisonMethod = 'pixel' | 'ssim';
23
38
  type FailureThresholdOptions = {
24
39
  /**
@@ -75,7 +90,7 @@ type ImageSnapshotSubjectOptions = {
75
90
  */
76
91
  subject?: string | undefined;
77
92
  };
78
- type ToMatchImageSnapshotOptions<M extends ComparisonMethod = 'pixel'> = ImageSnapshotTimeoutOptions & ImageSnapshotKeyOptions & ImageSnapshotCompareOptions<M> & {
93
+ type ToMatchImageSnapshotOptions<M extends ComparisonMethod = 'pixel'> = ImageSnapshotTimeoutOptions & ImageSnapshotKeyOptions & ImageSnapshotCompareOptions<M> & ReviewImageOptions & {
79
94
  /**
80
95
  * Expect the matcher to fail.
81
96
  * If it passes, it will throw an error with details.
@@ -130,8 +145,8 @@ type ImageSnapshotComparisonInfo = {
130
145
  * Base64 encoded diff image.
131
146
  */
132
147
  diff?: string | undefined;
133
- } & ImageSnapshotTimeoutOptions & FailureThresholdOptions & (SsimComparisonOptions | PixelComparisonOptions);
134
- type MatchImageSnapshotOptions = ImageSnapshotTimeoutOptions & ImageSnapshotCompareOptions<any> & ImageSnapshotKeyOptions & {
148
+ } & ImageSnapshotTimeoutOptions & FailureThresholdOptions & ReviewImageOptions & (SsimComparisonOptions | PixelComparisonOptions);
149
+ type MatchImageSnapshotOptions = ImageSnapshotTimeoutOptions & ImageSnapshotCompareOptions<any> & ImageSnapshotKeyOptions & ReviewImageOptions & {
135
150
  /**
136
151
  * When `true`, write a missing baseline on first run (see {@link ToMatchImageSnapshotOptions} `createMissingBaseline`).
137
152
  */
@@ -147,5 +162,5 @@ type SetupVisOptions<GM extends Record<string, any> | unknown = unknown> = {
147
162
  createMissingBaseline?: boolean | undefined;
148
163
  };
149
164
  //#endregion
150
- export { Awaitable, ComparisonMethod, FailureThresholdOptions, ImageSnapshotCompareOptions, ImageSnapshotComparisonInfo, ImageSnapshotKeyOptions, ImageSnapshotSubjectOptions, ImageSnapshotTimeoutOptions, MatchImageSnapshotOptions, PageImageSnapshotOptions, PixelComparisonOptions, PixelDiffOptions, SetupVisOptions, SnapshotTestMeta, SsimComparisonOptions, type SsimDiffOptions, ToMatchImageSnapshotOptions, ToMatchPageImageSnapshotOptions };
165
+ export { Awaitable, ComparisonMethod, FailureThresholdOptions, ImageSnapshotCompareOptions, ImageSnapshotComparisonInfo, ImageSnapshotKeyOptions, ImageSnapshotSubjectOptions, ImageSnapshotTimeoutOptions, MatchImageSnapshotOptions, PageImageSnapshotOptions, PixelComparisonOptions, PixelDiffOptions, ReviewImageOptions, SetupVisOptions, SnapshotTestMeta, SsimComparisonOptions, type SsimDiffOptions, ToMatchImageSnapshotOptions, ToMatchPageImageSnapshotOptions };
151
166
  //# sourceMappingURL=types.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/shared/types.ts"],"sourcesContent":[],"mappings":";;;;;;KAKY,eAAe,IAAI,YAAY;UAE1B,2BAAA;EAFL;;;;;EAEK,OAAA,CAAA,EAAA,MAAA,GAAA,SAAA;AASjB;AAOY,UAPK,uBAAA,CAOW;EAEhB;AA6BZ;;EAKuB,WAAA,CAAA,EAAA,MAAA,GAAA,SAAA;;AAAD,KApCV,gBAAA,GAoCU,OAAA,GAAA,MAAA;AAKV,KAvCA,uBAAA,GAuCsB;EAQtB;AAEZ;;;;EACG,oBAAA,CAAA,EAAA,OAAA,GAAA,SAAA,GAAA,SAAA;EACuB;;;;AAG1B;AAWA;;;;;;;AAmBA;AAIA;;;;;;EAGY,gBAAA,CAAA,EAAgB,MAAA,GAAA,SAAA;CAEJ;AAAb,KAhEC,qBAgED,CAAA,IAAA,MAAA,CAAA,GAAA;EAAR,gBAAA,EA/DgB,CA+DhB;EAAI;AAIP;;EA+BC,WAAA,CAAA,EA9Fc,OA8Fd,CA9FsB,eA8FtB,CAAA,GAAA,SAAA;CACC;AAA8C,KA1FpC,sBA0FoC,CAAA,IAAA,OAAA,CAAA,GAAA;EAEpC,gBAAA,CAAA,EA3FQ,CA2FR,GAAA,SAAyB;EAAG;;;EAEhB,WAAA,CAAA,EAzFT,gBAyFS,GAAA,SAAA;AAOxB,CAAA;AAAuC,KA7F3B,gBAAA,GAAmB,UA6FQ,CAAA,OA7FU,UA6FV,CAAA,CAAA,CAAA,CAAA;AAGvB,KA9FJ,2BA8FI,CAAA,UA9FkC,gBA8FlC,GAAA,OAAA,CAAA,GAAA,CA9FiE,CA8FjE,SAAA,MAAA,GA7Fb,qBA6Fa,CA7FS,CA6FT,CAAA,GA5Fb,sBA4Fa,CA5FU,CA4FV,CAAA,CAAA,GA3Ff,uBA2Fe;AAA4B,KAzFhC,2BAAA,GAyFgC;EAClB;;;;;;;;EAME,OAAA,CAAA,EAAA,MAAA,GAAA,SAAA;CAAb;AAAkB,KArFrB,2BAqFqB,CAAA,UArFiB,gBAqFjB,GAAA,OAAA,CAAA,GArF+C,2BAqF/C,GApFhC,uBAoFgC,GAnFhC,2BAmFgC,CAnFJ,CAmFI,CAAA,GAAA;EAAI;;;;EAJ3B,YAAA,CAAA,EAAA,OAAA,GAAA,SAAA;;;;;;;;;;;UA9DO,wBAAA;;;KAIL,0CAA0C,8BAA8B,4BAA4B,KAC/G;KAEW,gBAAA;;KAET,IAAA,IAAQ,aAAa;;;KAIZ,2BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA8BR,8BACH,2BACC,wBAAwB;KAEd,yBAAA,GAA4B,8BACvC,mCACA;;;;;;KAOW,2BAA2B;+BAGvB,4BAA4B,kDAC/B,aAAa,KAAK,IAAI,OAC1B,mBAAmB,kCACxB,qCAGc,4BAA4B,kDAC/B,aAAa,KAAK,IAAI,OAC1B,mBAAmB"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/shared/types.ts"],"sourcesContent":[],"mappings":";;;;;;KAKY,eAAe,IAAI,YAAY;UAE1B,2BAAA;EAFL;;;;;EAEK,OAAA,CAAA,EAAA,MAAA,GAAA,SAAA;AASjB;AAOiB,UAPA,uBAAA,CAOkB;EAgBvB;AAEZ;AA6BA;EACmB,WAAA,CAAA,EAAA,MAAA,GAAA,SAAA;;AAIJ,UApDE,kBAAA,CAoDF;EAAO;AAKtB;AAQA;AAEA;;;;;;;;;EAKY,WAAA,CAAA,EAAA,OAAA,GAAA,SAA2B;AAWvC;AAAkD,KAnEtC,gBAAA,GAmEsC,OAAA,GAAA,MAAA;AAA8B,KAjEpE,uBAAA,GAiEoE;EAC/E;;;;;EAmBgB,oBAAA,CAAA,EAAA,OAAwB,GAAA,SAAA,GAAA,SAAA;EAI7B;;;;;;AAGZ;;;;;AAMA;;;;;;;AAmCA;EAAwC,gBAAA,CAAA,EAAA,MAAA,GAAA,SAAA;CACvC;AACA,KA1GW,qBA0GX,CAAA,IAAA,MAAA,CAAA,GAAA;EACA,gBAAA,EA1GkB,CA0GlB;EAAkB;AAOnB;;EAGgB,WAAA,CAAA,EAhHD,OAgHC,CAhHO,eAgHP,CAAA,GAAA,SAAA;CAA4B;AAC/B,KA5GD,sBA4GC,CAAA,IAAA,OAAA,CAAA,GAAA;EAAkB,gBAAA,CAAA,EA3GX,CA2GW,GAAA,SAAA;EAAI;;;EAKjB,WAAA,CAAA,EA5GH,gBA4GG,GAAA,SAAA;CAA4B;AAClB,KA1GhB,gBAAA,GAAmB,UA0GH,CAAA,OA1GqB,UA0GrB,CAAA,CAAA,CAAA,CAAA;AAAb,KAxGH,2BAwGG,CAAA,UAxGmC,gBAwGnC,GAAA,OAAA,CAAA,GAAA,CAxGkE,CAwGlE,SAAA,MAAA,GAvGZ,qBAuGY,CAvGU,CAuGV,CAAA,GAtGZ,sBAsGY,CAtGW,CAsGX,CAAA,CAAA,GArGd,uBAqGc;AAAkB,KAnGrB,2BAAA,GAmGqB;EAAI;;;;;;;;;;KAxFzB,sCAAsC,8BAA8B,8BAC/E,0BACA,4BAA4B,KAC5B;;;;;;;;;;;;;;;;UAiBgB,wBAAA;;;KAIL,0CAA0C,8BAA8B,4BAA4B,KAC/G;KAEW,gBAAA;;KAET,IAAA,IAAQ,aAAa;;;KAIZ,2BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA8BR,8BACH,0BACA,sBACC,wBAAwB;KAEd,yBAAA,GAA4B,8BACvC,mCACA,0BACA;;;;;;KAOW,2BAA2B;+BAGvB,4BAA4B,kDAC/B,aAAa,KAAK,IAAI,OAC1B,mBAAmB,kCACxB,qCAGc,4BAA4B,kDAC/B,aAAa,KAAK,IAAI,OAC1B,mBAAmB"}
package/llms.txt ADDED
@@ -0,0 +1,54 @@
1
+ # vitest-plugin-vis
2
+
3
+ > vitest-plugin-vis: Vitest visual testing plugin. Image snapshot testing for Vitest browser mode, with a Storybook integration.
4
+
5
+ This file is generated by `packages/storybook-addon-vis/scripts/generate-llms-txt.mjs` from the packages' `exports` maps, their source, and the documentation pages. Do not edit it by hand; run `pnpm docs:llms` instead.
6
+
7
+ If an image snapshot test fails, read [Reviewing Failures with Agents](https://repobuddy.github.io/visual-testing/?path=/docs/reviewing-failures-with-agents--docs) before opening any image.
8
+
9
+ Image snapshots are stored in the repository under `__vis__/`. Within it, `__baselines__` holds the expected images, `__results__` holds the images from the latest run, and `__diffs__` holds the diff images of failed comparisons. Next to each diff image, `<name>.review.png` is the review image of the changed regions and `<name>.regions.json` lists those regions.
10
+
11
+ Import from the entry points listed below, not from package internals. Documentation pages list the names they cover; names without a page have only their type declarations.
12
+
13
+ ## Start here
14
+
15
+ - [storybook-addon-vis: Reviewing Failures with Agents](https://repobuddy.github.io/visual-testing/?path=/docs/reviewing-failures-with-agents--docs): When an image snapshot fails, a coding agent often opens the baseline and the result and compares them.
16
+
17
+ ## Packages
18
+
19
+ - [vitest-plugin-vis](https://www.npmjs.com/package/vitest-plugin-vis): Vitest visual testing plugin. Peer dependencies: @vitest/browser ^4, @vitest/browser-playwright ^4 (optional), @vitest/browser-webdriverio ^4 (optional), vitest ^4.
20
+
21
+ ## Documentation
22
+
23
+ - [vitest-plugin-vis: Overview](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/overview.mdx): vitest-plugin-vis provides visual testing for tests running in Vitest Browser Mode.
24
+ - [vitest-plugin-vis: auto snapshots/setAutoSnapshotOptions](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/auto_snapshots/auto_snapshot_options.mdx): A function to configure visual snapshot testing options for the current test or test suite. Documents: setAutoSnapshotOptions.
25
+ - [vitest-plugin-vis: config](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/config/overview.mdx): Configuration refers to configuring the plugin in your `vitest.config.ts` file.
26
+ - [vitest-plugin-vis: config/trimCommonFolder](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/config/trim_common_folder.mdx): The `trimCommonFolder` function is a utility that removes common folder prefixes from file paths to generate cleaner snapshot sub-paths. Documents: trimCommonFolder.
27
+ - [vitest-plugin-vis: config/vis](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/config/vis.mdx): `vis` from `vitest-plugin-vis/config` is the Vitest plugin of `vitest-plugin-vis`. Documents: vis.
28
+ - [vitest-plugin-vis: expect/toMatchImageSnapshot](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/client/expect/to_match_image_snapshot.mdx): `expect().toMatchImageSnapshot()` is a custom matcher that compares the current DOM to a snapshot image.
29
+ - [vitest-plugin-vis: page/hasImageSnapshot](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/client/page/has_image_snapshot.mdx): `page.hasImageSnapshot()` is a utility method that checks if an image snapshot exists for the current test.
30
+ - [vitest-plugin-vis: page/toMatchImageSnapshot](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/client/page/to_match_image_snapshot.mdx): `page.toMatchImageSnapshot()` is a method that captures and compares full page or viewport screenshots against baseline images.
31
+ - [vitest-plugin-vis: setup](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/setup/overview.mdx): `vitest-plugin-vis/setup` provides the API to setup the plugin on the client side.
32
+ - [vitest-plugin-vis: setup/createVis](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/setup/create_vis.mdx): Import `createVis` from `vitest-plugin-vis/setup-api` (not from `vitest-plugin-vis/setup`). Documents: createVis.
33
+ - [vitest-plugin-vis: setup/vis](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/setup/vis.mdx): `vis` object from `vitest-plugin-vis/setup` provides the API to setup the plugin on the client side. Documents: vis.
34
+
35
+ ## Entry points
36
+
37
+ - [`vitest-plugin-vis`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/index.ts): function: setAutoSnapshotOptions; types: Awaitable, ComparisonMethod, FailureThresholdOptions, ImageSnapshotCompareOptions, ImageSnapshotComparisonInfo, ImageSnapshotKeyOptions, ImageSnapshotSubjectOptions, ImageSnapshotTimeoutOptions, MatchImageSnapshotOptions, MetaTask, PageImageSnapshotOptions, PixelComparisonOptions, PixelDiffOptions, ReviewImageOptions, SetupVisOptions, SnapshotTestMeta, SsimComparisonOptions, SsimDiffOptions, ToMatchImageSnapshotOptions, ToMatchPageImageSnapshotOptions (21 exports, 1 with a documentation page).
38
+ - [`vitest-plugin-vis/client-api`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/client-api.ts): functions: autoSnapshotMatcher, hasImageSnapshotAction, loadImageSnapshotResultsAction, matchImageSnapshotAction, matchPageImageSnapshotAction, setAutoSnapshotOptions, toTaskId; types: Awaitable, ComparisonMethod, FailureThresholdOptions, ImageSnapshotCompareOptions, ImageSnapshotComparisonInfo, ImageSnapshotKeyOptions, ImageSnapshotMatcher, ImageSnapshotSubjectOptions, ImageSnapshotTimeoutOptions, MatchImageSnapshotOptions, MetaTask, PageImageSnapshotOptions, PixelComparisonOptions, PixelDiffOptions, ReviewImageOptions, SetupVisOptions, SnapshotTestMeta, SsimComparisonOptions, SsimDiffOptions, ToMatchImageSnapshotOptions, ToMatchPageImageSnapshotOptions; values: BASELINE_DIR, CHANGED_REGIONS_SUFFIX, DIFF_DIR, NAME, RESULT_DIR, REVIEW_IMAGE_SUFFIX, SNAPSHOT_ROOT_DIR, success (36 exports, 1 with a documentation page).
39
+ - [`vitest-plugin-vis/config`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/config.ts): functions: trimCommonFolder, vis; types: ComparisonMethod, PixelComparisonOptions, PixelDiffOptions, SsimComparisonOptions, SsimDiffOptions, VisOptions (8 exports, 2 with a documentation page).
40
+ - [`vitest-plugin-vis/presets/auto`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/presets/auto.ts): no named exports; it is imported for its side effects.
41
+ - [`vitest-plugin-vis/presets/manual`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/presets/manual.ts): no named exports; it is imported for its side effects.
42
+ - [`vitest-plugin-vis/server-utils`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/server-utils.ts): functions: getSnapshotRootDir, resolveLegacySnapshotFileRelativePath, resolveSnapshotSubpathWithinLimits, trimCommonFolder; values: BASELINE_DIR, CHANGED_REGIONS_SUFFIX, DIFF_DIR, REVIEW_IMAGE_SUFFIX, SNAPSHOT_ROOT_DIR (9 exports, 1 with a documentation page).
43
+ - [`vitest-plugin-vis/setup`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/setup.ts): value: vis (1 export, 1 with a documentation page).
44
+ - [`vitest-plugin-vis/setup-api`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/setup-api.ts): function: createVis; type: VisClientConfigurator (2 exports, 1 with a documentation page).
45
+ - [`vitest-plugin-vis/testing`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/testing.ts): values: UNI_IMAGE_DATA, UNI_PNG_BASE64, UNI_PNG_URL (3 exports, 0 with a documentation page).
46
+ - [`vitest-plugin-vis/testing/node`](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/exports/testing/node.ts): functions: getVisOption, stubSuite; values: UNI_IMAGE_DATA, UNI_PNG_BASE64, UNI_PNG_URL (5 exports, 0 with a documentation page).
47
+
48
+ ## Optional
49
+
50
+ - [vitest-plugin-vis: Changelog](https://github.com/repobuddy/visual-testing/blob/main/packages/vitest-plugin-vis/src/changelog.mdx): Release history of vitest-plugin-vis.
51
+ - [Generator](https://github.com/repobuddy/visual-testing/blob/main/packages/storybook-addon-vis/scripts/generate-llms-txt.mjs): Source of this file. Run it with `--gaps` to list the undocumented exports.
52
+
53
+ Documentation coverage: 5 of 50 distinct exported declarations are named on a documentation page; 8 carry a doc comment.
54
+ 12 of the pages above are not published on the documentation site; they link to their source on GitHub.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-plugin-vis",
3
- "version": "5.1.8",
3
+ "version": "5.2.1",
4
4
  "description": "Vitest visual testing plugin",
5
5
  "keywords": [
6
6
  "image-snapshot",
@@ -45,7 +45,14 @@
45
45
  "files": [
46
46
  "dist",
47
47
  "src",
48
- "!**/*.{spec,test,unit,accept,integrate,system,perf,stress}.*"
48
+ "llms.txt",
49
+ "plugin.json",
50
+ ".claude-plugin",
51
+ ".cursor-plugin",
52
+ ".codex-plugin",
53
+ "skills",
54
+ "!**/*.{spec,test,unit,accept,integrate,system,perf,stress}.*",
55
+ "CHANGELOG.md"
49
56
  ],
50
57
  "dependencies": {
51
58
  "dedent": "^1.7.2",
@@ -57,7 +64,7 @@
57
64
  "pngjs": "^7.0.0",
58
65
  "rimraf": "^6.1.3",
59
66
  "ssim.js": "^3.5.0",
60
- "type-plus": "8.0.0"
67
+ "type-plus": "8.0.1"
61
68
  },
62
69
  "devDependencies": {
63
70
  "@repobuddy/storybook": "^2.33.4",
package/plugin.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "vitest-plugin-vis",
4
+ "version": "5.1.8",
5
+ "description": "Review failed image snapshots from vitest-plugin-vis cheaply: read the message, then the review image.",
6
+ "author": {
7
+ "name": "Homa Wong",
8
+ "email": "homawong@gmail.com",
9
+ "url": "https://github.com/unional"
10
+ },
11
+ "homepage": "https://github.com/repobuddy/visual-testing/tree/main/packages/vitest-plugin-vis",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/repobuddy/visual-testing",
15
+ "directory": "packages/vitest-plugin-vis"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": ["image-snapshot", "vitest", "visual-test"],
19
+ "extensions": {
20
+ "org.cyberuni.universal-plugin": {
21
+ "vendors": ["claude-code", "cursor", "codex", "copilot-cli"]
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,18 @@
1
+ # review-image-snapshot-failures
2
+
3
+ Reviews a failed image snapshot through its review image instead of the full images, which costs far fewer tokens.
4
+
5
+ ## When to use
6
+
7
+ An agent loads it when a visual test fails with a `Snapshot ... mismatched` error from `toMatchImageSnapshot`, or when it is asked to run visual tests or update image snapshots. It is not a slash command (`user-invocable: false`).
8
+
9
+ ## What it does
10
+
11
+ 1. Reads the error message: pixel count, size change, changed regions, image paths.
12
+ 2. Opens the review image, not the full Expected, Actual, or Difference images.
13
+ 3. Opens a full image only when the message says the review image was skipped.
14
+ 4. Updates a baseline (`-u`) only for an intended change, and reports rendering drift instead.
15
+
16
+ ## Install
17
+
18
+ It ships as a plugin in both the `vitest-plugin-vis` and `storybook-addon-vis` npm packages (`plugin.json` and `skills/` in the package), identical in each. The measured token costs are on the "Reviewing Failures with Agents" page.
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: review-image-snapshot-failures
3
+ user-invocable: false
4
+ description: Reviews a failed image snapshot through its review image instead of the full images, at a fraction of the tokens. Applies when a visual test fails with a `Snapshot ... mismatched` error from toMatchImageSnapshot, or when asked to run visual tests or update image snapshots, even if the request never mentions images.
5
+ ---
6
+
7
+ # Review image snapshot failures
8
+
9
+ Use the review image of a failed image snapshot before opening any full image.
10
+
11
+ ## Run the tests
12
+
13
+ Run the project's own test command (for example `vitest run`). Do not pass `-u` to make a failure go away. Updating baselines is step 5 below, and only for an intended change.
14
+
15
+ Snapshots live under `__vis__/`: `__baselines__` is expected, `__results__` is the latest run, `__diffs__` holds the diff of a failed comparison. Next to each diff, `<name>.review.png` is the review image and `<name>.regions.json` lists the changed regions.
16
+
17
+ ## When a snapshot fails
18
+
19
+ 1. **Read the error message first.** It gives the pixel count, any size change, the changed regions (`Changed:`), and the image paths. It is text and costs little.
20
+ 2. **Open the review image** (`Review:`), not the full images. For each region it shows Expected, Actual, and the changed pixels in red, side by side. Open the full Expected, Actual, or Difference image only if the review image leaves the question open.
21
+ 3. **If the message says the review image was skipped, read why.**
22
+ - The image size changed: open only the Actual image. A size change means the layout changed.
23
+ - Too many regions, or they cover most of the image: open the Difference image. Red pixels changed; yellow pixels are anti-aliasing and do not count. Open Expected and Actual only if the Difference image does not show what changed.
24
+ 4. **If there is no `Review:` line**, the review image is turned off (`reviewImage: false`). The `Changed:` line still gives each region. Crop Expected and Actual to those regions with any image tool available, and open the crops.
25
+ 5. **Decide what the change is.**
26
+ - Intended by the task: update the baseline (run the tests with `-u`).
27
+ - Same content, only the edges of text or shapes differ: rendering drift (fonts, operating system, browser). Report it; do not update the baseline.
28
+ - Anything else is a regression. Fix it or report it.
29
+
30
+ Full explanation and measured token costs: the "Reviewing Failures with Agents" page, shipped as `src/reviewing_failures_with_agents.mdx` in `storybook-addon-vis`.
31
+
32
+ ## Validate
33
+
34
+ - The error message was read before any image was opened.
35
+ - No full Expected, Actual, or Difference image was opened while a `Review:` image existed and answered the question.
36
+ - `-u` was not run unless the change is what the task intended.
37
+ - A change that only differs at the edges of text or shapes was reported as rendering drift, and its baseline is unchanged (`git diff` shows no file under `__baselines__`).
@@ -120,6 +120,39 @@ it('should allow percentage differences', async ({ expect }) => {
120
120
  })
121
121
  ```
122
122
 
123
+ ### Review Image
124
+
125
+ #### `reviewImage`
126
+
127
+ When a snapshot mismatches, the matcher finds the changed regions and writes two files next to the diff image:
128
+
129
+ - `<name>.regions.json`: the image size and one box per changed region.
130
+ - `<name>.review.png`: for each region, the baseline, result, and diff crops side by side.
131
+
132
+ The failure message lists the regions and the review image:
133
+
134
+ ```txt
135
+ Changed: 2 regions: 106x37 at (0,0), 40x20 at (300,512)
136
+ Review: ./__vis__/local/__diffs__/button.stories.tsx/primary-1.review.png (baseline, result, diff of each region)
137
+ ```
138
+
139
+ Open the review image first: it shows only what changed, at full contrast, and is much smaller than the full images.
140
+ The review image is skipped when cropping does not help:
141
+ the image size changed, there are more than 5 regions, or the regions cover more than half of the image.
142
+ The message then says why, and you open the `Actual` or `Difference` image instead.
143
+
144
+ Set `reviewImage: false` to not write the review image.
145
+
146
+ ```ts
147
+ it('should not write a review image', async ({ expect }) => {
148
+ render(<MyComponent />)
149
+
150
+ await expect(document.body).toMatchImageSnapshot({
151
+ reviewImage: false
152
+ })
153
+ })
154
+ ```
155
+
123
156
  ### Comparison Methods
124
157
 
125
158
  #### `comparisonMethod`
@@ -1,23 +1,24 @@
1
- export function toImageData(base64: string) {
2
- return new Promise<ImageData>((resolve, reject) => {
3
- const img = new Image()
4
- img.src = `data:image/png;base64,${base64}`
5
-
6
- img.onload = () => {
7
- const canvas = document.createElement('canvas')
8
- canvas.width = img.width
9
- canvas.height = img.height
10
- const ctx = canvas.getContext('2d')!
11
- ctx.drawImage(img, 0, 0)
12
- resolve(ctx.getImageData(0, 0, canvas.width, canvas.height))
13
- }
14
-
15
- img.onerror = () => {
16
- reject(new Error('Failed to load image'))
17
- }
18
- })
1
+ export async function toImageData(base64: string) {
2
+ // `createImageBitmap` + `close()` releases the decoded pixels right away.
3
+ // `new Image()` + `drawImage` leaves a full-size decode in Chromium's image decode cache.
4
+ let bitmap: ImageBitmap
5
+ try {
6
+ const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob()
7
+ bitmap = await createImageBitmap(blob)
8
+ } catch (cause) {
9
+ throw new Error('Failed to load image', { cause })
10
+ }
11
+ try {
12
+ const canvas = document.createElement('canvas')
13
+ canvas.width = bitmap.width
14
+ canvas.height = bitmap.height
15
+ const ctx = canvas.getContext('2d')!
16
+ ctx.drawImage(bitmap, 0, 0)
17
+ return ctx.getImageData(0, 0, canvas.width, canvas.height)
18
+ } finally {
19
+ bitmap.close()
20
+ }
19
21
  }
20
-
21
22
  export async function toDataURL(imageData: ImageData) {
22
23
  const canvas = document.createElement('canvas')
23
24
  canvas.width = imageData.width
@@ -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'