@crvy/rprtr 0.0.4 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.7] - 2026-06-05
9
+
10
+ ### Ci
11
+
12
+ - Upgrade actions and node version for npm trusted publishing
13
+ ## [0.0.6] - 2026-06-05
14
+
15
+ ### Added
16
+
17
+ - Add named screenshot baseline resolver
18
+ - Resolve unnamed and duplicate screenshot baselines
19
+ - Resolve passed screenshot baselines through Playwright rules
20
+ - Persist approval resolver metadata in report state
21
+ - Emit approval resolver metadata from reporter
22
+ - Reuse resolver for approval routing
23
+ - Report mixed approval routing outcomes
24
+
25
+ ### Changed
26
+
27
+ - Separate resolver attachment and artifact names
28
+ - Tighten screenshot declaration typing
29
+
30
+ ### Documentation
31
+
32
+ - Add passed screenshot baseline resolution design
33
+ - Add passed screenshot baseline resolution plan
34
+ - Document passed screenshot baseline resolution options
35
+ - Narrow approval path claims
36
+ - Add template-aware approval routing design
37
+ - Add template-aware approval routing plan
38
+ - Align approval routing docs with resolver behavior
39
+ - Clarify approval routing configuration scope
40
+
41
+ ### Fixed
42
+
43
+ - Sanitize named screenshot resolver inputs
44
+ - Preserve visual names in named baseline targets
45
+ - Disambiguate named screenshot baseline resolution
46
+ - Restore reporter declaration compatibility
47
+ - Use safe artifact names for copied baselines
48
+ - Encode copied baseline artifact paths safely
49
+ - Neutralize traversal segments in baseline artifact paths
50
+ - Encode saved attachment artifact paths safely
51
+ - Use stable traversal-safe artifact path encoding
52
+ - Preserve approval metadata through schemas
53
+ - Preserve visual name fallback for older payloads
54
+ - Use configured testDir for approval resolution
55
+ - Use configured configDir for approval resolution
56
+ - Require POST for approve-all routing
57
+ - Require POST for approve routing
58
+ - Report no-actual bulk approval outcomes
59
+ - Honor approval api results in client state
60
+
61
+ ### Testing
62
+
63
+ - Cover exact approval routing cases
64
+
65
+ ### Ci
66
+
67
+ - Switch npm publish to OIDC trusted publishing
68
+ ## [0.0.5] - 2026-05-14
69
+
70
+ ### Added
71
+
72
+ - Classify passed visual assertions in report state
73
+ - Emit declared visual names for screenshot steps
74
+ - Refresh report state from disk changes
75
+ - Label passed visual fallback states in ui
76
+
77
+ ### Documentation
78
+
79
+ - Describe passed screenshot fallback modes
80
+
81
+ ### Fixed
82
+
83
+ - **ci:** Checkout hooks submodule
84
+
85
+ ### Miscellaneous
86
+
87
+ - Add you-lint-not-pass hook integration
88
+ - Exclude hooks repo from root checks
8
89
  ## [0.0.4] - 2026-04-23
9
90
 
10
91
  ### Added
package/README.md CHANGED
@@ -50,12 +50,15 @@ npx crvy-rprtr ./artifacts
50
50
 
51
51
  ## Reporter Options
52
52
 
53
- | Option | Type | Default | Description |
54
- | ------------------- | -------- | ------------------------------ | -------------------------------------------------- |
55
- | `serverUrl` | `string` | `"ws://localhost:3000"` | WebSocket URL of the Crvy Rprtr server |
56
- | `screenshotDir` | `string` | `"./screenshots"` | Directory for saving screenshot artifacts |
57
- | `offlineReportPath` | `string` | `"./crvy-rprtr-{worker}.json"` | Path for offline report when server is unavailable |
58
- | `reportHtmlPath` | `string` | `"./crvy-rprtr.html"` | Path for the browser-openable static report HTML |
53
+ | Option | Type | Default | Description |
54
+ | ---------------------------------------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
55
+ | `serverUrl` | `string` | `"ws://localhost:3000"` | WebSocket URL of the Crvy Rprtr server |
56
+ | `screenshotDir` | `string` | `"./screenshots"` | Directory for saving screenshot artifacts |
57
+ | `offlineReportPath` | `string` | `"./crvy-rprtr-{worker}.json"` | Path for offline report when server is unavailable |
58
+ | `reportHtmlPath` | `string` | `"./crvy-rprtr.html"` | Path for the browser-openable static report HTML |
59
+ | `playwrightSnapshotDir` | `string` | `undefined` | Override the Playwright snapshot directory used for passed-baseline display lookup |
60
+ | `playwrightSnapshotPathTemplate` | `string` | `undefined` | Mirror Playwright `snapshotPathTemplate` for passed-baseline display resolution |
61
+ | `playwrightToHaveScreenshotPathTemplate` | `string` | `undefined` | Mirror Playwright `expect.toHaveScreenshot.pathTemplate` for passed-baseline display; takes precedence |
59
62
 
60
63
  ## Server CLI Options
61
64
 
@@ -77,12 +80,14 @@ Explicit flags override the paths derived from `artifact-dir`.
77
80
  | `--screenshot-dir` | `-s` | `./screenshots` | Screenshot directory path |
78
81
  | `--report-path` | `-r` | `./report.json` | Report JSON file path or directory containing `report.json` and `crvy-rprtr-*.json` files |
79
82
 
83
+ The CLI currently exposes only these flags. Approval-routing resolver overrides are available through the programmatic server API, not additional CLI flags.
84
+
80
85
  ## How It Works
81
86
 
82
87
  1. **During test runs:** The Playwright reporter sends test results to the server via WebSocket in real-time and records the same run for artifact export.
83
88
  2. **After tests complete:** A static `crvy-rprtr.html` artifact is written for direct browser viewing, and offline report JSON is also written if the server was unavailable.
84
89
  3. **In the browser:** The UI shows all screenshot tests with side-by-side, swap, slide, and blend diff views.
85
- 4. **Approving changes:** Start the UI server and click "Approve" to accept a new screenshot as the baseline. The server copies the actual image to the expected snapshot location.
90
+ 4. **Approving changes:** Start the UI server and click "Approve" or "Approve All" to accept a new screenshot as the baseline. Approval uses the same exact Playwright-aware resolver as passed-baseline display, including default layouts, unnamed screenshots, duplicate names, and custom templates when the running server was started with matching resolver options. Those approval-routing options are read from the server startup path, not from reporter options. If the server starts without explicit resolver overrides, approval falls back to the server defaults instead. If Crvy Rprtr cannot determine exactly one target path, it leaves the image unresolved instead of guessing.
86
91
 
87
92
  ## Offline Mode
88
93
 
@@ -93,6 +98,21 @@ When the server isn't running during tests, the reporter automatically falls bac
93
98
  - On test completion, a self-contained `crvy-rprtr.html` is written for direct browser review
94
99
  - When the server starts, it loads and merges all `crvy-rprtr-*.json` files from the offline report directory
95
100
 
101
+ ## Passed Screenshot Modes
102
+
103
+ Crvy Rprtr keeps passed Playwright screenshot assertions visible in two fallback modes when Playwright does not emit a full passing comparison payload:
104
+
105
+ - `baseline-only`: Crvy Rprtr resolved the exact expected snapshot path and copied that baseline into the screenshot directory, so the UI can show the stored baseline.
106
+ - `declared-only`: the screenshot assertion was detected, but Crvy Rprtr could not resolve one exact snapshot file and therefore keeps the honest text-only fallback.
107
+
108
+ Exact resolution mirrors Playwright's screenshot naming and template rules for default layouts, unnamed screenshots, and explicitly configured custom templates. For slash-containing named screenshot titles, Crvy Rprtr may check both Playwright-equivalent variants and only uses a baseline when exactly one candidate wins.
109
+
110
+ Crvy Rprtr does not auto-read Playwright config for snapshot template discovery. If your suite uses a custom snapshot layout, pass the matching `playwrightSnapshotDir`, `playwrightSnapshotPathTemplate`, or `playwrightToHaveScreenshotPathTemplate` reporter options explicitly for passed-baseline display.
111
+
112
+ Approval routing uses the same resolver, but it reads its resolver settings from the server startup path today. The current user-facing place to pass those overrides is `startServer({...})`, via options such as `configDir`, `playwrightTestDir`, `playwrightSnapshotDir`, `playwrightSnapshotPathTemplate`, and `playwrightToHaveScreenshotPathTemplate`. The CLI does not expose flags for those overrides, so `npx crvy-rprtr` uses the server defaults when they are omitted. For slash-containing named screenshot titles, Crvy Rprtr only updates the baseline when one exact Playwright-equivalent target can be determined.
113
+
114
+ When the server is running, Crvy Rprtr also refreshes the UI after report JSON or screenshot artifacts change on disk.
115
+
96
116
  ## Programmatic API
97
117
 
98
118
  ```ts
@@ -113,6 +133,20 @@ await startServer({
113
133
  })
114
134
  ```
115
135
 
136
+ If you need approval routing to follow a custom Playwright snapshot layout, pass the resolver options when starting the server programmatically:
137
+
138
+ ```ts
139
+ await startServer({
140
+ port: 3000,
141
+ screenshotDir: './screenshots',
142
+ reportPath: './artifacts',
143
+ configDir: process.cwd(),
144
+ playwrightTestDir: './tests',
145
+ playwrightSnapshotDir: './tests/__screenshots__',
146
+ playwrightToHaveScreenshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}',
147
+ })
148
+ ```
149
+
116
150
  The programmatic server API works in both Node 22+ and Bun.
117
151
 
118
152
  ## Development
@@ -0,0 +1,500 @@
1
+ // src/snapshot-path-resolver.ts
2
+ import { createHash } from "crypto";
3
+ import { basename, dirname, extname, join, parse, relative, resolve } from "path";
4
+ var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
5
+ var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
6
+ function isUnsafeFilePathCharacter(character) {
7
+ const codePoint = character.codePointAt(0);
8
+ return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
9
+ }
10
+ function sanitizeForFilePath(value) {
11
+ return Array.from(value).reduce(
12
+ (state, character) => {
13
+ const unsafeCharacter = isUnsafeFilePathCharacter(character);
14
+ return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
15
+ value: `${state.value}-`,
16
+ previousCharacterWasUnsafe: true
17
+ } : {
18
+ value: `${state.value}${character}`,
19
+ previousCharacterWasUnsafe: false
20
+ };
21
+ },
22
+ {
23
+ value: "",
24
+ previousCharacterWasUnsafe: false
25
+ }
26
+ ).value;
27
+ }
28
+ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
29
+ if (value.length <= length) {
30
+ return value;
31
+ }
32
+ const hash = createHash("sha1").update(value).digest("hex");
33
+ const middle = `-${hash.slice(0, 5)}-`;
34
+ const start = Math.floor((length - middle.length) / 2);
35
+ const end = length - middle.length - start;
36
+ return value.slice(0, start) + middle + value.slice(-end);
37
+ }
38
+ function sanitizeFilePathBeforeExtension(filePath, extension = extname(filePath)) {
39
+ const base = filePath.slice(0, filePath.length - extension.length);
40
+ return sanitizeForFilePath(base) + extension;
41
+ }
42
+ function addSuffixToFilePath(filePath, suffix) {
43
+ const extension = extname(filePath);
44
+ return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
45
+ }
46
+ function normalizedSnapshotDir(config) {
47
+ return resolve(config.configDir, config.snapshotDir);
48
+ }
49
+ function templateValue(template, token, value) {
50
+ return template.replace(
51
+ new RegExp(`\\{(.)?${token}\\}`, "g"),
52
+ (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
53
+ );
54
+ }
55
+ function applyTemplate(input, nameArgument, extension) {
56
+ const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
57
+ const relativeTestFilePath = relative(input.config.testDir, input.testFile);
58
+ const parsed = parse(relativeTestFilePath);
59
+ const tokens = [
60
+ ["testDir", input.config.testDir],
61
+ ["snapshotDir", normalizedSnapshotDir(input.config)],
62
+ ["snapshotSuffix", input.config.snapshotSuffix],
63
+ ["testFileDir", parsed.dir],
64
+ ["platform", process.platform],
65
+ ["projectName", sanitizeForFilePath(input.config.projectName)],
66
+ ["testName", ""],
67
+ ["testFileName", parsed.base],
68
+ ["testFilePath", relativeTestFilePath],
69
+ ["arg", nameArgument],
70
+ ["ext", extension]
71
+ ];
72
+ const snapshotPath = tokens.reduce(
73
+ (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
74
+ template
75
+ );
76
+ return resolve(input.config.configDir, snapshotPath);
77
+ }
78
+ function removeExtension(filePath, extension = extname(filePath)) {
79
+ return filePath.slice(0, filePath.length - extension.length);
80
+ }
81
+ function snapshotNameParts(declaredName) {
82
+ const extension = extname(declaredName) || ".png";
83
+ return {
84
+ extension,
85
+ filePath: extname(declaredName) === "" ? `${declaredName}${extension}` : declaredName
86
+ };
87
+ }
88
+ function filePathForOccurrence(filePath, occurrenceIndex) {
89
+ return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
90
+ }
91
+ function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
92
+ return {
93
+ visualName: declaration.visualName,
94
+ attachmentBaseName: declaration.visualName,
95
+ artifactBaseName: sanitizeForFilePath(declaration.visualName),
96
+ snapshotPath: applyTemplate(input, nameArgument, extension)
97
+ };
98
+ }
99
+ function resolveStringCallTarget(input, declaration) {
100
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
101
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
102
+ const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
103
+ const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
104
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
105
+ }
106
+ function resolveArrayCallTarget(input, declaration) {
107
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
108
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
109
+ const nameArgument = join(dirname(occurrenceFilePath), basename(occurrenceFilePath, extension));
110
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
111
+ }
112
+ function resolveNamedTarget(input, declaration) {
113
+ if (!declaration.declaredName.includes("/")) {
114
+ return resolveStringCallTarget(input, declaration);
115
+ }
116
+ const stringCallTarget = resolveStringCallTarget(input, declaration);
117
+ const arrayCallTarget = resolveArrayCallTarget(input, declaration);
118
+ if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
119
+ return stringCallTarget;
120
+ }
121
+ if (input.snapshotPathExists === void 0) {
122
+ return void 0;
123
+ }
124
+ const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
125
+ const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
126
+ if (stringCallTargetExists === arrayCallTargetExists) {
127
+ return void 0;
128
+ }
129
+ return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
130
+ }
131
+ function reporterTitlesWithoutProjectAndFile(reporterTitlePath) {
132
+ return reporterTitlePath.slice(3).filter((part) => part !== "");
133
+ }
134
+ function anonymousName(reporterTitlePath, occurrenceIndex) {
135
+ const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath).join(" ")} ${occurrenceIndex}.png`;
136
+ return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
137
+ }
138
+ function resolveTarget(input, declaration) {
139
+ switch (declaration.kind) {
140
+ case "named":
141
+ return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
142
+ case "unnamed": {
143
+ const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
144
+ const extension = ".png";
145
+ const nameArgument = join(dirname(anonymousFileName), basename(anonymousFileName, extension));
146
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
147
+ }
148
+ }
149
+ }
150
+ function resolveBaselineTargets(input) {
151
+ return input.declarations.flatMap((declaration) => {
152
+ const resolvedTarget = resolveTarget(input, declaration);
153
+ return resolvedTarget === void 0 ? [] : [resolvedTarget];
154
+ });
155
+ }
156
+
157
+ // src/report-utils.ts
158
+ function normalizeScreenshotsBaseUrl(baseUrl) {
159
+ return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
160
+ }
161
+ function classifyImage(image) {
162
+ if (image.actual !== void 0 || image.diff !== void 0) {
163
+ return "comparison";
164
+ }
165
+ if (image.expect !== void 0) {
166
+ return "baseline-only";
167
+ }
168
+ return "declared-only";
169
+ }
170
+ function withImageSource(image) {
171
+ return {
172
+ ...image,
173
+ source: classifyImage(image)
174
+ };
175
+ }
176
+ function getDeclaredVisualNames(visualNames, visualDeclarations) {
177
+ return visualDeclarations?.map(({ visualName }) => visualName) ?? visualNames;
178
+ }
179
+ function copyVisualDeclarations(visualDeclarations) {
180
+ return visualDeclarations?.map((visualDeclaration) => ({ ...visualDeclaration }));
181
+ }
182
+ function mergeDeclaredImages(images, visualNames) {
183
+ const names = /* @__PURE__ */ new Set([...Object.keys(images), ...visualNames]);
184
+ return Object.fromEntries(
185
+ Array.from(names, (name) => {
186
+ const current = images[name] ?? {};
187
+ return [name, withImageSource(current)];
188
+ })
189
+ );
190
+ }
191
+ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/") {
192
+ const images = {};
193
+ const baseUrl = normalizeScreenshotsBaseUrl(screenshotsBaseUrl);
194
+ for (const attachment of attachments) {
195
+ if (attachment.contentType !== "image/png") continue;
196
+ const match = attachment.name.match(/^(.+?)-(actual|expected|diff)(?:\.png)?$/);
197
+ if (match === null) continue;
198
+ const baseName = match[1];
199
+ const role = match[2];
200
+ if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
201
+ images[baseName] ??= {};
202
+ const url = `${baseUrl}${attachment.path}`;
203
+ const img = images[baseName];
204
+ if (img !== null && img !== void 0) {
205
+ if (role === "actual") img.actual = url;
206
+ else if (role === "expected") img.expect = url;
207
+ else if (role === "diff") img.diff = url;
208
+ }
209
+ }
210
+ for (const key of Object.keys(images)) {
211
+ const img = images[key];
212
+ if (img?.actual !== void 0 && img?.expect !== void 0 && img?.diff === void 0) {
213
+ delete img.expect;
214
+ }
215
+ }
216
+ return mergeDeclaredImages(images, []);
217
+ }
218
+ function mapStatus(status) {
219
+ switch (status) {
220
+ case "passed":
221
+ return "success";
222
+ case "failed":
223
+ return "failed";
224
+ case "skipped":
225
+ return "pending";
226
+ default:
227
+ return "unknown";
228
+ }
229
+ }
230
+
231
+ // src/report-state.ts
232
+ function isCurrentArtifact(image) {
233
+ return image?.actual !== void 0 || image?.expect !== void 0 || image?.diff !== void 0;
234
+ }
235
+ function isReusablePassingImage(image) {
236
+ const source = image.source ?? classifyImage(image);
237
+ if (source === "comparison") {
238
+ return image.actual !== void 0 && image.diff === void 0;
239
+ }
240
+ return source === "baseline-only";
241
+ }
242
+ function hasReusablePassingImages(images) {
243
+ return Object.values(images).some((img) => img !== null && img !== void 0 && isReusablePassingImage(img));
244
+ }
245
+ function preservePreviousPassingImages(test, status, images) {
246
+ if (status !== "passed") {
247
+ return images;
248
+ }
249
+ const previousImages = test.results?.[0]?.images ?? {};
250
+ if (!hasReusablePassingImages(previousImages)) {
251
+ return images;
252
+ }
253
+ return Object.entries(previousImages).reduce((currentImages, [name, previousImage]) => {
254
+ if (!Object.hasOwn(currentImages, name) || previousImage === void 0 || !isReusablePassingImage(previousImage) || isCurrentArtifact(currentImages[name])) {
255
+ return currentImages;
256
+ }
257
+ return {
258
+ ...currentImages,
259
+ [name]: {
260
+ ...previousImage,
261
+ source: previousImage.source ?? classifyImage(previousImage)
262
+ }
263
+ };
264
+ }, images);
265
+ }
266
+ function countDiffImages(images) {
267
+ return Object.values(images).filter((img) => img?.diff !== null && img?.diff !== void 0).length;
268
+ }
269
+ function createMutableReportState(screenshotDir = "./screenshots") {
270
+ return {
271
+ reportData: {
272
+ isRunning: false,
273
+ tests: {},
274
+ browsers: ["chromium"],
275
+ isUpdateMode: false,
276
+ screenshotDir
277
+ },
278
+ currentRunIds: /* @__PURE__ */ new Set()
279
+ };
280
+ }
281
+ function applyTestBeginEvent(state, data) {
282
+ const { id, title, titlePath, browser, location } = data;
283
+ state.currentRunIds.add(id);
284
+ state.reportData.tests[id] ??= {
285
+ id,
286
+ titlePath: titlePath ?? [],
287
+ browser: browser ?? "",
288
+ title: title ?? "",
289
+ location,
290
+ status: "running"
291
+ };
292
+ return state.reportData.tests[id];
293
+ }
294
+ function applyTestEndEvent(state, data, options = {}) {
295
+ const test = state.reportData.tests[data.id];
296
+ if (test === void 0) {
297
+ return null;
298
+ }
299
+ test.status = mapStatus(data.status);
300
+ const resultStatus = data.status === "passed" ? "success" : data.status === "failed" ? "failed" : "pending";
301
+ const visualDeclarations = copyVisualDeclarations(data.visualDeclarations);
302
+ const images = preservePreviousPassingImages(
303
+ test,
304
+ data.status,
305
+ mergeDeclaredImages(
306
+ attachmentsToImages(data.attachments, options.screenshotsBaseUrl),
307
+ getDeclaredVisualNames(data.visualNames, visualDeclarations)
308
+ )
309
+ );
310
+ const diffCount = countDiffImages(images);
311
+ const hasDiffs = diffCount > 0;
312
+ if (hasDiffs) {
313
+ test.approved = null;
314
+ }
315
+ test.results = [
316
+ {
317
+ status: resultStatus,
318
+ retries: 0,
319
+ images,
320
+ visualDeclarations,
321
+ error: data.error,
322
+ duration: data.duration
323
+ }
324
+ ];
325
+ return {
326
+ test,
327
+ diffCount
328
+ };
329
+ }
330
+ function finalizeRunEvent(state) {
331
+ state.reportData.isRunning = false;
332
+ state.reportData.tests = Object.fromEntries(
333
+ Object.entries(state.reportData.tests).filter(([id]) => state.currentRunIds.has(id))
334
+ );
335
+ state.currentRunIds.clear();
336
+ const runTests = Object.values(state.reportData.tests).filter((test) => test !== void 0);
337
+ return {
338
+ passed: runTests.filter((test) => test.status === "success").length,
339
+ failed: runTests.filter((test) => test.status === "failed").length,
340
+ pending: runTests.filter((test) => test.status === "pending").length
341
+ };
342
+ }
343
+
344
+ // src/schemas.ts
345
+ import { z } from "zod";
346
+ var LocationSchema = z.object({
347
+ file: z.string(),
348
+ line: z.number()
349
+ });
350
+ var VisualSourceSchema = z.enum(["comparison", "baseline-only", "declared-only"]);
351
+ var ImagesSchema = z.object({
352
+ actual: z.string().optional(),
353
+ expect: z.string().optional(),
354
+ diff: z.string().optional(),
355
+ error: z.string().optional(),
356
+ source: VisualSourceSchema.optional()
357
+ });
358
+ var ScreenshotDeclarationSchema = z.discriminatedUnion("kind", [
359
+ z.object({
360
+ visualName: z.string(),
361
+ kind: z.literal("named"),
362
+ declaredName: z.string(),
363
+ snapshotBaseName: z.string(),
364
+ occurrenceIndex: z.number()
365
+ }),
366
+ z.object({
367
+ visualName: z.string(),
368
+ kind: z.literal("unnamed"),
369
+ occurrenceIndex: z.number()
370
+ })
371
+ ]);
372
+ var AttachmentSchema = z.object({
373
+ name: z.string(),
374
+ path: z.string(),
375
+ contentType: z.string()
376
+ });
377
+ var TestStatusSchema = z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
378
+ var TestResultStatusSchema = z.enum(["failed", "success", "pending"]);
379
+ var TestResultSchema = z.object({
380
+ status: TestResultStatusSchema,
381
+ retries: z.number(),
382
+ images: z.record(z.string(), ImagesSchema).optional(),
383
+ visualDeclarations: z.array(ScreenshotDeclarationSchema).optional(),
384
+ error: z.string().optional(),
385
+ duration: z.number().optional()
386
+ });
387
+ var TestDataSchema = z.object({
388
+ id: z.string(),
389
+ titlePath: z.array(z.string()),
390
+ browser: z.string(),
391
+ title: z.string(),
392
+ skip: z.union([z.boolean(), z.string()]).optional(),
393
+ retries: z.number().optional(),
394
+ status: TestStatusSchema.optional(),
395
+ results: z.array(TestResultSchema).optional(),
396
+ approved: z.record(z.string(), z.number()).nullable().optional(),
397
+ attachments: z.array(AttachmentSchema).optional(),
398
+ location: LocationSchema.optional()
399
+ });
400
+ var CrvyRprtrTestSchema = TestDataSchema.extend({
401
+ checked: z.boolean()
402
+ });
403
+ var CrvyRprtrSuiteSchema = z.lazy(
404
+ () => z.object({
405
+ path: z.array(z.string()),
406
+ skip: z.boolean(),
407
+ status: TestStatusSchema.optional(),
408
+ opened: z.boolean(),
409
+ checked: z.boolean(),
410
+ indeterminate: z.boolean(),
411
+ children: z.record(z.string(), z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
412
+ })
413
+ );
414
+ var WebSocketMessageSchema = z.object({
415
+ type: z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
416
+ data: z.unknown()
417
+ });
418
+ var TestBeginDataSchema = z.object({
419
+ id: z.string(),
420
+ title: z.string(),
421
+ titlePath: z.array(z.string()),
422
+ browser: z.string(),
423
+ location: LocationSchema
424
+ });
425
+ var TestEndDataSchema = z.object({
426
+ id: z.string(),
427
+ status: z.enum(["passed", "failed", "skipped"]),
428
+ attachments: z.array(AttachmentSchema),
429
+ visualNames: z.array(z.string()).default([]),
430
+ visualDeclarations: z.preprocess(
431
+ (value) => value === null ? void 0 : value,
432
+ z.array(ScreenshotDeclarationSchema).optional()
433
+ ),
434
+ error: z.string().optional(),
435
+ duration: z.number().optional()
436
+ });
437
+ var ReportDataSchema = z.object({
438
+ isRunning: z.boolean(),
439
+ tests: z.record(z.string(), TestDataSchema),
440
+ browsers: z.array(z.string()),
441
+ isUpdateMode: z.boolean(),
442
+ screenshotDir: z.string()
443
+ });
444
+ var LoadedReportDataSchema = z.object({
445
+ tests: z.record(z.string(), TestDataSchema).optional(),
446
+ isUpdateMode: z.boolean().optional()
447
+ });
448
+ var OfflineEventSchema = z.object({
449
+ type: z.enum(["test-begin", "test-end", "run-end"]),
450
+ data: z.unknown(),
451
+ timestamp: z.number(),
452
+ workerIndex: z.number()
453
+ });
454
+ var OfflineReportSchema = z.object({
455
+ version: z.number(),
456
+ generatedAt: z.string(),
457
+ workers: z.number(),
458
+ events: z.array(OfflineEventSchema)
459
+ });
460
+ var ApproveRequestBodySchema = z.object({
461
+ id: z.string(),
462
+ retry: z.number(),
463
+ image: z.string()
464
+ });
465
+ var ReportApiResponseSchema = z.object({
466
+ tests: z.record(z.string(), TestDataSchema),
467
+ isUpdateMode: z.boolean().optional()
468
+ });
469
+ var ClientBootstrapDataSchema = z.object({
470
+ report: ReportApiResponseSchema.extend({
471
+ isUpdateMode: z.boolean()
472
+ }),
473
+ liveUpdates: z.boolean(),
474
+ approvalEnabled: z.boolean(),
475
+ approvalMessage: z.string().optional()
476
+ });
477
+ var ImagesViewModeSchema = z.enum(["side-by-side", "swap", "slide", "blend"]);
478
+ function safeParse(schema, data) {
479
+ const result = schema.safeParse(data);
480
+ if (result.success) {
481
+ return result.data;
482
+ }
483
+ return null;
484
+ }
485
+
486
+ export {
487
+ createMutableReportState,
488
+ applyTestBeginEvent,
489
+ applyTestEndEvent,
490
+ finalizeRunEvent,
491
+ WebSocketMessageSchema,
492
+ TestBeginDataSchema,
493
+ TestEndDataSchema,
494
+ LoadedReportDataSchema,
495
+ OfflineReportSchema,
496
+ ApproveRequestBodySchema,
497
+ ClientBootstrapDataSchema,
498
+ safeParse,
499
+ resolveBaselineTargets
500
+ };