@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/dist/reporter.cjs CHANGED
@@ -34,8 +34,9 @@ __export(reporter_exports, {
34
34
  default: () => reporter_default
35
35
  });
36
36
  module.exports = __toCommonJS(reporter_exports);
37
+ var import_fs = require("fs");
37
38
  var import_promises2 = require("fs/promises");
38
- var import_path2 = require("path");
39
+ var import_path3 = require("path");
39
40
  var import_p_limit = __toESM(require("p-limit"), 1);
40
41
 
41
42
  // src/report-artifact.ts
@@ -47,6 +48,36 @@ var import_url = require("url");
47
48
  function normalizeScreenshotsBaseUrl(baseUrl) {
48
49
  return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
49
50
  }
51
+ function classifyImage(image) {
52
+ if (image.actual !== void 0 || image.diff !== void 0) {
53
+ return "comparison";
54
+ }
55
+ if (image.expect !== void 0) {
56
+ return "baseline-only";
57
+ }
58
+ return "declared-only";
59
+ }
60
+ function withImageSource(image) {
61
+ return {
62
+ ...image,
63
+ source: classifyImage(image)
64
+ };
65
+ }
66
+ function getDeclaredVisualNames(visualNames, visualDeclarations) {
67
+ return visualDeclarations?.map(({ visualName }) => visualName) ?? visualNames;
68
+ }
69
+ function copyVisualDeclarations(visualDeclarations) {
70
+ return visualDeclarations?.map((visualDeclaration) => ({ ...visualDeclaration }));
71
+ }
72
+ function mergeDeclaredImages(images, visualNames) {
73
+ const names = /* @__PURE__ */ new Set([...Object.keys(images), ...visualNames]);
74
+ return Object.fromEntries(
75
+ Array.from(names, (name) => {
76
+ const current = images[name] ?? {};
77
+ return [name, withImageSource(current)];
78
+ })
79
+ );
80
+ }
50
81
  function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/") {
51
82
  const images = {};
52
83
  const baseUrl = normalizeScreenshotsBaseUrl(screenshotsBaseUrl);
@@ -57,7 +88,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
57
88
  const baseName = match[1];
58
89
  const role = match[2];
59
90
  if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
60
- images[baseName] ??= { actual: "" };
91
+ images[baseName] ??= {};
61
92
  const url = `${baseUrl}${attachment.path}`;
62
93
  const img = images[baseName];
63
94
  if (img !== null && img !== void 0) {
@@ -68,10 +99,11 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
68
99
  }
69
100
  for (const key of Object.keys(images)) {
70
101
  const img = images[key];
71
- if (img?.actual !== null && img?.actual !== void 0 && img?.expect !== null && img?.expect !== void 0 && img?.diff === void 0)
102
+ if (img?.actual !== void 0 && img?.expect !== void 0 && img?.diff === void 0) {
72
103
  delete img.expect;
104
+ }
73
105
  }
74
- return images;
106
+ return mergeDeclaredImages(images, []);
75
107
  }
76
108
  function mapStatus(status) {
77
109
  switch (status) {
@@ -87,17 +119,39 @@ function mapStatus(status) {
87
119
  }
88
120
 
89
121
  // src/report-state.ts
90
- function hasReviewablePassingImages(images) {
91
- return Object.values(images).some(
92
- (img) => img !== null && img !== void 0 && img.actual !== null && img.actual !== void 0 && img.diff === void 0
93
- );
122
+ function isCurrentArtifact(image) {
123
+ return image?.actual !== void 0 || image?.expect !== void 0 || image?.diff !== void 0;
124
+ }
125
+ function isReusablePassingImage(image) {
126
+ const source = image.source ?? classifyImage(image);
127
+ if (source === "comparison") {
128
+ return image.actual !== void 0 && image.diff === void 0;
129
+ }
130
+ return source === "baseline-only";
131
+ }
132
+ function hasReusablePassingImages(images) {
133
+ return Object.values(images).some((img) => img !== null && img !== void 0 && isReusablePassingImage(img));
94
134
  }
95
135
  function preservePreviousPassingImages(test, status, images) {
96
- if (status !== "passed" || Object.keys(images).length > 0) {
136
+ if (status !== "passed") {
97
137
  return images;
98
138
  }
99
139
  const previousImages = test.results?.[0]?.images ?? {};
100
- return hasReviewablePassingImages(previousImages) ? previousImages : images;
140
+ if (!hasReusablePassingImages(previousImages)) {
141
+ return images;
142
+ }
143
+ return Object.entries(previousImages).reduce((currentImages, [name, previousImage]) => {
144
+ if (!Object.hasOwn(currentImages, name) || previousImage === void 0 || !isReusablePassingImage(previousImage) || isCurrentArtifact(currentImages[name])) {
145
+ return currentImages;
146
+ }
147
+ return {
148
+ ...currentImages,
149
+ [name]: {
150
+ ...previousImage,
151
+ source: previousImage.source ?? classifyImage(previousImage)
152
+ }
153
+ };
154
+ }, images);
101
155
  }
102
156
  function countDiffImages(images) {
103
157
  return Object.values(images).filter((img) => img?.diff !== null && img?.diff !== void 0).length;
@@ -133,10 +187,15 @@ function applyTestEndEvent(state, data, options = {}) {
133
187
  return null;
134
188
  }
135
189
  test.status = mapStatus(data.status);
190
+ const resultStatus = data.status === "passed" ? "success" : data.status === "failed" ? "failed" : "pending";
191
+ const visualDeclarations = copyVisualDeclarations(data.visualDeclarations);
136
192
  const images = preservePreviousPassingImages(
137
193
  test,
138
194
  data.status,
139
- attachmentsToImages(data.attachments, options.screenshotsBaseUrl)
195
+ mergeDeclaredImages(
196
+ attachmentsToImages(data.attachments, options.screenshotsBaseUrl),
197
+ getDeclaredVisualNames(data.visualNames, visualDeclarations)
198
+ )
140
199
  );
141
200
  const diffCount = countDiffImages(images);
142
201
  const hasDiffs = diffCount > 0;
@@ -145,9 +204,10 @@ function applyTestEndEvent(state, data, options = {}) {
145
204
  }
146
205
  test.results = [
147
206
  {
148
- status: data.status === "passed" ? "success" : "failed",
207
+ status: resultStatus,
149
208
  retries: 0,
150
209
  images,
210
+ visualDeclarations,
151
211
  error: data.error,
152
212
  duration: data.duration
153
213
  }
@@ -177,23 +237,40 @@ var LocationSchema = import_zod.z.object({
177
237
  file: import_zod.z.string(),
178
238
  line: import_zod.z.number()
179
239
  });
240
+ var VisualSourceSchema = import_zod.z.enum(["comparison", "baseline-only", "declared-only"]);
180
241
  var ImagesSchema = import_zod.z.object({
181
- actual: import_zod.z.string(),
242
+ actual: import_zod.z.string().optional(),
182
243
  expect: import_zod.z.string().optional(),
183
244
  diff: import_zod.z.string().optional(),
184
- error: import_zod.z.string().optional()
245
+ error: import_zod.z.string().optional(),
246
+ source: VisualSourceSchema.optional()
185
247
  });
248
+ var ScreenshotDeclarationSchema = import_zod.z.discriminatedUnion("kind", [
249
+ import_zod.z.object({
250
+ visualName: import_zod.z.string(),
251
+ kind: import_zod.z.literal("named"),
252
+ declaredName: import_zod.z.string(),
253
+ snapshotBaseName: import_zod.z.string(),
254
+ occurrenceIndex: import_zod.z.number()
255
+ }),
256
+ import_zod.z.object({
257
+ visualName: import_zod.z.string(),
258
+ kind: import_zod.z.literal("unnamed"),
259
+ occurrenceIndex: import_zod.z.number()
260
+ })
261
+ ]);
186
262
  var AttachmentSchema = import_zod.z.object({
187
263
  name: import_zod.z.string(),
188
264
  path: import_zod.z.string(),
189
265
  contentType: import_zod.z.string()
190
266
  });
191
267
  var TestStatusSchema = import_zod.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
268
+ var TestResultStatusSchema = import_zod.z.enum(["failed", "success", "pending"]);
192
269
  var TestResultSchema = import_zod.z.object({
193
- status: import_zod.z.enum(["failed", "success"]),
270
+ status: TestResultStatusSchema,
194
271
  retries: import_zod.z.number(),
195
- // eslint-disable-next-line typescript/no-unsafe-type-assertion
196
272
  images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
273
+ visualDeclarations: import_zod.z.array(ScreenshotDeclarationSchema).optional(),
197
274
  error: import_zod.z.string().optional(),
198
275
  duration: import_zod.z.number().optional()
199
276
  });
@@ -221,7 +298,6 @@ var CrvyRprtrSuiteSchema = import_zod.z.lazy(
221
298
  opened: import_zod.z.boolean(),
222
299
  checked: import_zod.z.boolean(),
223
300
  indeterminate: import_zod.z.boolean(),
224
- // eslint-disable-next-line typescript/no-unsafe-type-assertion
225
301
  children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
226
302
  })
227
303
  );
@@ -240,6 +316,11 @@ var TestEndDataSchema = import_zod.z.object({
240
316
  id: import_zod.z.string(),
241
317
  status: import_zod.z.enum(["passed", "failed", "skipped"]),
242
318
  attachments: import_zod.z.array(AttachmentSchema),
319
+ visualNames: import_zod.z.array(import_zod.z.string()).default([]),
320
+ visualDeclarations: import_zod.z.preprocess(
321
+ (value) => value === null ? void 0 : value,
322
+ import_zod.z.array(ScreenshotDeclarationSchema).optional()
323
+ ),
243
324
  error: import_zod.z.string().optional(),
244
325
  duration: import_zod.z.number().optional()
245
326
  });
@@ -412,18 +493,244 @@ async function writeReportArtifact(options) {
412
493
  }
413
494
 
414
495
  // src/reporter-utils.ts
415
- function extractScreenshotNames(steps) {
416
- const names = [];
496
+ var NAMED_SCREENSHOT_STEP_TITLE = /toHaveScreenshot\((.+?)\)/;
497
+ var UNNAMED_SCREENSHOT_STEP_TITLE = /^Expect "toHaveScreenshot"(?:\s|$)/;
498
+ var SYNTHETIC_SCREENSHOT_PREFIX = "__unnamed-screenshot-";
499
+ function addVisualSuffix(visualName, occurrenceIndex) {
500
+ if (occurrenceIndex === 1) {
501
+ return visualName;
502
+ }
503
+ const slashIndex = visualName.lastIndexOf("/");
504
+ return slashIndex === -1 ? `${visualName}-${occurrenceIndex - 1}` : `${visualName.slice(0, slashIndex + 1)}${visualName.slice(slashIndex + 1)}-${occurrenceIndex - 1}`;
505
+ }
506
+ function normalizeNamedScreenshot(titleMatch, state) {
507
+ const unquotedName = titleMatch.trim().replace(/^['"`]|['"`]$/g, "");
508
+ if (unquotedName === "") {
509
+ return null;
510
+ }
511
+ const normalizedPath = unquotedName.replace(/\\/g, "/");
512
+ const declaredName = normalizedPath.replace(/\.png$/, "");
513
+ if (declaredName === "") {
514
+ return null;
515
+ }
516
+ const occurrenceIndex = (state.namedOccurrences.get(declaredName) ?? 0) + 1;
517
+ state.namedOccurrences.set(declaredName, occurrenceIndex);
518
+ return {
519
+ visualName: addVisualSuffix(declaredName, occurrenceIndex),
520
+ kind: "named",
521
+ declaredName,
522
+ snapshotBaseName: declaredName,
523
+ occurrenceIndex
524
+ };
525
+ }
526
+ function visitStep(step, state) {
527
+ const declarationsBeforeChildren = state.declarations.length;
528
+ for (const nestedStep of step.steps) {
529
+ visitStep(nestedStep, state);
530
+ }
531
+ const namedMatch = step.title.match(NAMED_SCREENSHOT_STEP_TITLE);
532
+ if (namedMatch?.[1] !== void 0) {
533
+ const declaration = normalizeNamedScreenshot(namedMatch[1], state);
534
+ if (declaration !== null) {
535
+ state.declarations.push(declaration);
536
+ return true;
537
+ }
538
+ }
539
+ const hasNestedScreenshotDeclaration = state.declarations.length > declarationsBeforeChildren;
540
+ if (UNNAMED_SCREENSHOT_STEP_TITLE.test(step.title) && !hasNestedScreenshotDeclaration) {
541
+ const occurrenceIndex = state.nextUnnamedIndex;
542
+ state.declarations.push({
543
+ visualName: `${SYNTHETIC_SCREENSHOT_PREFIX}${occurrenceIndex}`,
544
+ kind: "unnamed",
545
+ occurrenceIndex
546
+ });
547
+ state.nextUnnamedIndex += 1;
548
+ return true;
549
+ }
550
+ return hasNestedScreenshotDeclaration;
551
+ }
552
+ function extractScreenshotDeclarations(steps) {
553
+ const state = {
554
+ declarations: [],
555
+ namedOccurrences: /* @__PURE__ */ new Map(),
556
+ nextUnnamedIndex: 1
557
+ };
417
558
  for (const step of steps) {
418
- const match = step.title.match(/toHaveScreenshot\((.+?)\)/);
419
- if (match?.[1] !== void 0 && match[1] !== "") names.push(match[1]);
420
- if (step.steps.length) names.push(...extractScreenshotNames(step.steps));
559
+ visitStep(step, state);
421
560
  }
422
- return names;
561
+ return state.declarations;
562
+ }
563
+
564
+ // src/snapshot-path-resolver.ts
565
+ var import_crypto = require("crypto");
566
+ var import_path2 = require("path");
567
+ var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
568
+ var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
569
+ function isUnsafeFilePathCharacter(character) {
570
+ const codePoint = character.codePointAt(0);
571
+ return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
572
+ }
573
+ function sanitizeForFilePath(value) {
574
+ return Array.from(value).reduce(
575
+ (state, character) => {
576
+ const unsafeCharacter = isUnsafeFilePathCharacter(character);
577
+ return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
578
+ value: `${state.value}-`,
579
+ previousCharacterWasUnsafe: true
580
+ } : {
581
+ value: `${state.value}${character}`,
582
+ previousCharacterWasUnsafe: false
583
+ };
584
+ },
585
+ {
586
+ value: "",
587
+ previousCharacterWasUnsafe: false
588
+ }
589
+ ).value;
590
+ }
591
+ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
592
+ if (value.length <= length) {
593
+ return value;
594
+ }
595
+ const hash = (0, import_crypto.createHash)("sha1").update(value).digest("hex");
596
+ const middle = `-${hash.slice(0, 5)}-`;
597
+ const start = Math.floor((length - middle.length) / 2);
598
+ const end = length - middle.length - start;
599
+ return value.slice(0, start) + middle + value.slice(-end);
600
+ }
601
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path2.extname)(filePath)) {
602
+ const base = filePath.slice(0, filePath.length - extension.length);
603
+ return sanitizeForFilePath(base) + extension;
604
+ }
605
+ function addSuffixToFilePath(filePath, suffix) {
606
+ const extension = (0, import_path2.extname)(filePath);
607
+ return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
608
+ }
609
+ function normalizedSnapshotDir(config) {
610
+ return (0, import_path2.resolve)(config.configDir, config.snapshotDir);
611
+ }
612
+ function templateValue(template, token, value) {
613
+ return template.replace(
614
+ new RegExp(`\\{(.)?${token}\\}`, "g"),
615
+ (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
616
+ );
617
+ }
618
+ function applyTemplate(input, nameArgument, extension) {
619
+ const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
620
+ const relativeTestFilePath = (0, import_path2.relative)(input.config.testDir, input.testFile);
621
+ const parsed = (0, import_path2.parse)(relativeTestFilePath);
622
+ const tokens = [
623
+ ["testDir", input.config.testDir],
624
+ ["snapshotDir", normalizedSnapshotDir(input.config)],
625
+ ["snapshotSuffix", input.config.snapshotSuffix],
626
+ ["testFileDir", parsed.dir],
627
+ ["platform", process.platform],
628
+ ["projectName", sanitizeForFilePath(input.config.projectName)],
629
+ ["testName", ""],
630
+ ["testFileName", parsed.base],
631
+ ["testFilePath", relativeTestFilePath],
632
+ ["arg", nameArgument],
633
+ ["ext", extension]
634
+ ];
635
+ const snapshotPath = tokens.reduce(
636
+ (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
637
+ template
638
+ );
639
+ return (0, import_path2.resolve)(input.config.configDir, snapshotPath);
640
+ }
641
+ function removeExtension(filePath, extension = (0, import_path2.extname)(filePath)) {
642
+ return filePath.slice(0, filePath.length - extension.length);
643
+ }
644
+ function snapshotNameParts(declaredName) {
645
+ const extension = (0, import_path2.extname)(declaredName) || ".png";
646
+ return {
647
+ extension,
648
+ filePath: (0, import_path2.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
649
+ };
650
+ }
651
+ function filePathForOccurrence(filePath, occurrenceIndex) {
652
+ return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
653
+ }
654
+ function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
655
+ return {
656
+ visualName: declaration.visualName,
657
+ attachmentBaseName: declaration.visualName,
658
+ artifactBaseName: sanitizeForFilePath(declaration.visualName),
659
+ snapshotPath: applyTemplate(input, nameArgument, extension)
660
+ };
661
+ }
662
+ function resolveStringCallTarget(input, declaration) {
663
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
664
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
665
+ const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
666
+ const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
667
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
668
+ }
669
+ function resolveArrayCallTarget(input, declaration) {
670
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
671
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
672
+ const nameArgument = (0, import_path2.join)((0, import_path2.dirname)(occurrenceFilePath), (0, import_path2.basename)(occurrenceFilePath, extension));
673
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
674
+ }
675
+ function resolveNamedTarget(input, declaration) {
676
+ if (!declaration.declaredName.includes("/")) {
677
+ return resolveStringCallTarget(input, declaration);
678
+ }
679
+ const stringCallTarget = resolveStringCallTarget(input, declaration);
680
+ const arrayCallTarget = resolveArrayCallTarget(input, declaration);
681
+ if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
682
+ return stringCallTarget;
683
+ }
684
+ if (input.snapshotPathExists === void 0) {
685
+ return void 0;
686
+ }
687
+ const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
688
+ const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
689
+ if (stringCallTargetExists === arrayCallTargetExists) {
690
+ return void 0;
691
+ }
692
+ return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
693
+ }
694
+ function reporterTitlesWithoutProjectAndFile(reporterTitlePath) {
695
+ return reporterTitlePath.slice(3).filter((part) => part !== "");
696
+ }
697
+ function anonymousName(reporterTitlePath, occurrenceIndex) {
698
+ const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath).join(" ")} ${occurrenceIndex}.png`;
699
+ return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
700
+ }
701
+ function resolveTarget(input, declaration) {
702
+ switch (declaration.kind) {
703
+ case "named":
704
+ return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
705
+ case "unnamed": {
706
+ const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
707
+ const extension = ".png";
708
+ const nameArgument = (0, import_path2.join)((0, import_path2.dirname)(anonymousFileName), (0, import_path2.basename)(anonymousFileName, extension));
709
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
710
+ }
711
+ }
712
+ }
713
+ function resolveBaselineTargets(input) {
714
+ return input.declarations.flatMap((declaration) => {
715
+ const resolvedTarget = resolveTarget(input, declaration);
716
+ return resolvedTarget === void 0 ? [] : [resolvedTarget];
717
+ });
423
718
  }
424
719
 
425
720
  // src/reporter.ts
721
+ var CURRENT_DIRECTORY_ARTIFACT_SEGMENT = "+dot+";
722
+ var PARENT_DIRECTORY_ARTIFACT_SEGMENT = "+dotdot+";
426
723
  var MAX_CONCURRENT_FILE_OPS = 5;
724
+ var SAFE_ARTIFACT_CHARACTER = /^[A-Za-z0-9._-]$/;
725
+ var encodeArtifactPathSegment = (segment) => {
726
+ if (segment === ".") return CURRENT_DIRECTORY_ARTIFACT_SEGMENT;
727
+ if (segment === "..") return PARENT_DIRECTORY_ARTIFACT_SEGMENT;
728
+ return Array.from(
729
+ segment,
730
+ (character) => SAFE_ARTIFACT_CHARACTER.test(character) ? character : encodeURIComponent(character)
731
+ ).join("");
732
+ };
733
+ var safeArtifactPath = (name) => name.split("/").map(encodeArtifactPathSegment).join("/");
427
734
  var CrvyRprtr = class {
428
735
  ws = null;
429
736
  serverUrl;
@@ -432,6 +739,11 @@ var CrvyRprtr = class {
432
739
  workerIndex;
433
740
  offlineReportPath;
434
741
  reportHtmlPath;
742
+ configDir = process.cwd();
743
+ testMetadata = /* @__PURE__ */ new Map();
744
+ playwrightSnapshotDir;
745
+ playwrightSnapshotPathTemplate;
746
+ playwrightToHaveScreenshotPathTemplate;
435
747
  isOfflineMode = false;
436
748
  hadOfflineMode = false;
437
749
  runEvents = [];
@@ -441,8 +753,12 @@ var CrvyRprtr = class {
441
753
  this.workerIndex = parseInt(process.env.TEST_WORKER_INDEX ?? "0", 10) || 0;
442
754
  this.offlineReportPath = options.offlineReportPath ?? `./crvy-rprtr-${this.workerIndex}.json`;
443
755
  this.reportHtmlPath = options.reportHtmlPath ?? "./crvy-rprtr.html";
756
+ this.playwrightSnapshotDir = options.playwrightSnapshotDir;
757
+ this.playwrightSnapshotPathTemplate = options.playwrightSnapshotPathTemplate;
758
+ this.playwrightToHaveScreenshotPathTemplate = options.playwrightToHaveScreenshotPathTemplate;
444
759
  }
445
760
  async onBegin(config, suite) {
761
+ this.configDir = config.configFile === void 0 ? config.rootDir : (0, import_path3.dirname)(config.configFile);
446
762
  console.log(`[CrvyRprtr] Starting run with ${suite.allTests().length} tests`);
447
763
  await (0, import_promises2.mkdir)(this.screenshotDir, { recursive: true });
448
764
  this.connect();
@@ -459,7 +775,7 @@ var CrvyRprtr = class {
459
775
  this.ws.onopen = () => {
460
776
  console.log("[CrvyRprtr] Connected to Crvy Rprtr server");
461
777
  this.isOfflineMode = false;
462
- for (const msg of this.queue) this.ws.send(msg);
778
+ for (const message of this.queue) this.ws.send(message);
463
779
  this.queue = [];
464
780
  };
465
781
  this.ws.onerror = (error) => {
@@ -470,140 +786,158 @@ var CrvyRprtr = class {
470
786
  console.log("[CrvyRprtr] Disconnected from Crvy Rprtr server");
471
787
  this.enableOfflineMode();
472
788
  };
473
- } catch (e) {
474
- console.error("[CrvyRprtr] Failed to connect:", e);
789
+ } catch (error) {
790
+ console.error("[CrvyRprtr] Failed to connect:", error);
475
791
  this.enableOfflineMode();
476
792
  }
477
793
  }
478
794
  enableOfflineMode() {
479
- if (!this.isOfflineMode) {
480
- this.isOfflineMode = true;
481
- this.hadOfflineMode = true;
482
- console.log("[CrvyRprtr] Offline mode enabled - events will be queued to file");
483
- }
795
+ if (this.isOfflineMode) return;
796
+ this.isOfflineMode = this.hadOfflineMode = true;
797
+ console.log("[CrvyRprtr] Offline mode enabled - events will be queued to file");
484
798
  }
485
- onTestBegin(test) {
799
+ describeTitlePath(test) {
486
800
  const titlePath = [];
487
- let suite = test.parent;
488
- while (suite && suite.type === "describe") {
801
+ for (let suite = test.parent; suite?.type === "describe"; suite = suite.parent)
489
802
  titlePath.unshift(suite.title);
490
- suite = suite.parent;
491
- }
803
+ return titlePath;
804
+ }
805
+ reporterTitlePath(test) {
806
+ return typeof test.titlePath === "function" ? test.titlePath() : ["", test.parent.project()?.name ?? "chromium", test.location.file, ...this.describeTitlePath(test), test.title];
807
+ }
808
+ onTestBegin(test) {
809
+ this.testMetadata.set(test.id, { reporterTitlePath: this.reporterTitlePath(test) });
492
810
  this.send({
493
811
  type: "test-begin",
494
812
  data: {
495
813
  id: test.id,
496
814
  title: test.title,
497
- titlePath,
815
+ titlePath: this.describeTitlePath(test),
498
816
  browser: test.parent.project()?.name ?? "chromium",
499
- location: {
500
- file: test.location.file,
501
- line: test.location.line
502
- }
817
+ location: { file: test.location.file, line: test.location.line }
503
818
  }
504
819
  });
505
820
  }
506
821
  async onTestEnd(test, result) {
822
+ const screenshotDeclarations = extractScreenshotDeclarations(result.steps);
507
823
  const savedAttachments = await this.saveAttachments(test.id, result);
508
- await this.copySnapshotBaselines(test, result, savedAttachments);
509
- this.send({
510
- type: "test-end",
511
- data: {
512
- id: test.id,
513
- title: test.title,
514
- status: result.status,
515
- attachments: savedAttachments,
516
- error: result.errors.length > 0 ? result.errors[0]?.message : void 0,
517
- duration: result.duration
518
- }
519
- });
824
+ try {
825
+ await this.copySnapshotBaselines(test, result.status, screenshotDeclarations, savedAttachments);
826
+ this.send({
827
+ type: "test-end",
828
+ data: {
829
+ id: test.id,
830
+ title: test.title,
831
+ status: result.status,
832
+ attachments: savedAttachments,
833
+ visualNames: screenshotDeclarations.map(({ visualName }) => visualName),
834
+ visualDeclarations: screenshotDeclarations,
835
+ error: result.errors.length > 0 ? result.errors[0]?.message : void 0,
836
+ duration: result.duration
837
+ }
838
+ });
839
+ } finally {
840
+ this.testMetadata.delete(test.id);
841
+ }
520
842
  }
521
- async copySnapshotBaselines(test, result, savedAttachments) {
522
- if (result.status !== "passed") return;
523
- const snapshotNames = extractScreenshotNames(result.steps);
524
- if (snapshotNames.length === 0) return;
525
- const projectName = test.parent.project()?.name ?? "chromium";
526
- const snapshotDir = `${test.location.file}-snapshots`;
527
- const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(test.id));
843
+ baselineInput(test, shots) {
844
+ const project = test.parent.project();
845
+ const snapshotDir = this.playwrightSnapshotDir ?? project?.snapshotDir;
846
+ if (project === void 0 || typeof project.testDir !== "string" || typeof snapshotDir !== "string") return null;
847
+ return {
848
+ testFile: test.location.file,
849
+ reporterTitlePath: this.testMetadata.get(test.id)?.reporterTitlePath ?? this.reporterTitlePath(test),
850
+ declarations: shots,
851
+ config: {
852
+ configDir: this.configDir,
853
+ testDir: project.testDir,
854
+ snapshotDir,
855
+ projectName: project.name,
856
+ snapshotSuffix: process.platform,
857
+ snapshotPathTemplate: this.playwrightSnapshotPathTemplate,
858
+ toHaveScreenshotPathTemplate: this.playwrightToHaveScreenshotPathTemplate
859
+ },
860
+ snapshotPathExists: import_fs.existsSync
861
+ };
862
+ }
863
+ async copyResolvedBaseline(safeTestId, testScreenshotDir, target, savedAttachments) {
864
+ const attachmentName = `${target.attachmentBaseName}-expected.png`;
865
+ const artifactPath = safeArtifactPath(attachmentName);
866
+ const destPath = (0, import_path3.join)(testScreenshotDir, artifactPath);
867
+ try {
868
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(destPath), { recursive: true });
869
+ await (0, import_promises2.copyFile)(target.snapshotPath, destPath);
870
+ savedAttachments.push({ name: attachmentName, path: `${safeTestId}/${artifactPath}`, contentType: "image/png" });
871
+ console.log(`[CrvyRprtr] Attached baseline: ${target.snapshotPath}`);
872
+ } catch {
873
+ }
874
+ }
875
+ async copySnapshotBaselines(test, status, screenshotDeclarations, savedAttachments) {
876
+ if (status !== "passed" || screenshotDeclarations.length === 0) return;
877
+ const input = this.baselineInput(test, screenshotDeclarations);
878
+ if (input === null) return;
879
+ const targets = resolveBaselineTargets(input);
880
+ if (targets.length === 0) return;
881
+ const safeTestId = this.sanitizeId(test.id);
882
+ const testScreenshotDir = (0, import_path3.join)(this.screenshotDir, safeTestId);
528
883
  const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
529
- const copyPromises = snapshotNames.map(
530
- (name) => limit(async () => {
531
- const baseName = name.replace(/\.png$/, "");
532
- const snapshotPath = (0, import_path2.join)(snapshotDir, `${baseName}-${projectName}-${process.platform}.png`);
533
- const destName = `${baseName}-expected`;
534
- const destPath = (0, import_path2.join)(testScreenshotDir, destName);
535
- try {
536
- await (0, import_promises2.mkdir)(testScreenshotDir, { recursive: true });
537
- await (0, import_promises2.copyFile)(snapshotPath, destPath);
538
- savedAttachments.push({
539
- name: destName,
540
- path: `${this.sanitizeId(test.id)}/${destName}`,
541
- contentType: "image/png"
542
- });
543
- console.log(`[CrvyRprtr] Attached baseline: ${snapshotPath}`);
544
- } catch {
545
- }
546
- })
884
+ await Promise.all(
885
+ targets.map(
886
+ (target) => limit(() => this.copyResolvedBaseline(safeTestId, testScreenshotDir, target, savedAttachments))
887
+ )
547
888
  );
548
- await Promise.all(copyPromises);
549
889
  }
550
890
  async saveAttachments(testId, result) {
551
891
  const savedAttachments = [];
552
- const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(testId));
892
+ const safeTestId = this.sanitizeId(testId);
893
+ const testScreenshotDir = (0, import_path3.join)(this.screenshotDir, safeTestId);
553
894
  const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
554
- const attachmentPromises = result.attachments.filter(
555
- (attachment) => attachment.contentType === "image/png" && attachment.path !== void 0
556
- ).map(
557
- (attachment) => limit(async () => {
558
- try {
559
- await (0, import_promises2.mkdir)(testScreenshotDir, { recursive: true });
560
- const fileName = attachment.name;
561
- const destPath = (0, import_path2.join)(testScreenshotDir, fileName);
562
- await (0, import_promises2.copyFile)(attachment.path, destPath);
563
- const attachmentData = {
564
- name: attachment.name,
565
- path: `${this.sanitizeId(testId)}/${fileName}`,
566
- contentType: attachment.contentType
567
- };
568
- savedAttachments.push(attachmentData);
569
- console.log(`[CrvyRprtr] Saved screenshot: ${destPath}`);
570
- } catch (e) {
571
- console.error(`[CrvyRprtr] Failed to save screenshot: ${attachment.path}`, e);
572
- const fallbackData = {
573
- name: attachment.name,
574
- path: attachment.path,
575
- contentType: attachment.contentType
576
- };
577
- savedAttachments.push(fallbackData);
578
- }
579
- })
895
+ await Promise.all(
896
+ result.attachments.filter(
897
+ (attachment) => attachment.contentType === "image/png" && attachment.path !== void 0
898
+ ).map(
899
+ (attachment) => limit(async () => {
900
+ try {
901
+ const artifactPath = safeArtifactPath(attachment.name);
902
+ const destPath = (0, import_path3.join)(testScreenshotDir, artifactPath);
903
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(destPath), { recursive: true });
904
+ await (0, import_promises2.copyFile)(attachment.path, destPath);
905
+ savedAttachments.push({
906
+ name: attachment.name,
907
+ path: `${safeTestId}/${artifactPath}`,
908
+ contentType: attachment.contentType
909
+ });
910
+ console.log(`[CrvyRprtr] Saved screenshot: ${destPath}`);
911
+ } catch (error) {
912
+ console.error(`[CrvyRprtr] Failed to save screenshot: ${attachment.path}`, error);
913
+ savedAttachments.push({
914
+ name: attachment.name,
915
+ path: attachment.path,
916
+ contentType: attachment.contentType
917
+ });
918
+ }
919
+ })
920
+ )
580
921
  );
581
- await Promise.all(attachmentPromises);
582
922
  return savedAttachments;
583
923
  }
584
924
  sanitizeId(id) {
585
925
  return id.replace(/[^a-zA-Z0-9-_]/g, "_");
586
926
  }
587
927
  async writeOfflineReport() {
588
- if (this.runEvents.length === 0) {
589
- console.log("[CrvyRprtr] No offline events to write");
590
- return;
591
- }
928
+ if (this.runEvents.length === 0) console.log("[CrvyRprtr] No offline events to write");
929
+ if (this.runEvents.length === 0) return;
592
930
  try {
593
931
  const report = {
594
932
  version: 1,
595
933
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
596
934
  workers: this.workerIndex + 1,
597
- events: this.runEvents.map((e) => ({
598
- ...e,
599
- timestamp: Date.now(),
600
- workerIndex: this.workerIndex
601
- }))
935
+ events: this.runEvents.map((event) => ({ ...event, timestamp: Date.now(), workerIndex: this.workerIndex }))
602
936
  };
603
937
  await (0, import_promises2.writeFile)(this.offlineReportPath, JSON.stringify(report, null, 2));
604
938
  console.log(`[CrvyRprtr] Wrote offline report: ${this.offlineReportPath}`);
605
- } catch (e) {
606
- console.error("[CrvyRprtr] Failed to write offline report:", e);
939
+ } catch (error) {
940
+ console.error("[CrvyRprtr] Failed to write offline report:", error);
607
941
  }
608
942
  }
609
943
  async writeStaticArtifact() {
@@ -614,51 +948,35 @@ var CrvyRprtr = class {
614
948
  reportHtmlPath: this.reportHtmlPath
615
949
  });
616
950
  console.log(`[CrvyRprtr] Wrote report artifact: ${this.reportHtmlPath}`);
617
- } catch (e) {
618
- console.error("[CrvyRprtr] Failed to write report artifact:", e);
951
+ } catch (error) {
952
+ console.error("[CrvyRprtr] Failed to write report artifact:", error);
619
953
  }
620
954
  }
621
955
  async onEnd(result) {
622
- this.send({
623
- type: "run-end",
624
- data: {
625
- status: result.status
626
- }
627
- });
956
+ this.send({ type: "run-end", data: { status: result.status } });
628
957
  await this.writeStaticArtifact();
629
- if (this.hadOfflineMode) {
630
- await this.writeOfflineReport();
631
- }
632
- await new Promise((resolve2) => {
633
- if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
634
- resolve2();
635
- return;
636
- }
637
- this.ws.onclose = () => {
638
- resolve2();
639
- };
640
- setTimeout(() => {
641
- this.ws?.close();
642
- resolve2();
643
- }, 1e3);
644
- this.ws.close();
958
+ if (this.hadOfflineMode) await this.writeOfflineReport();
959
+ await new Promise((resolve3) => {
960
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
961
+ this.ws.onclose = () => {
962
+ resolve3();
963
+ };
964
+ setTimeout(() => {
965
+ this.ws?.close();
966
+ resolve3();
967
+ }, 1e3);
968
+ this.ws.close();
969
+ } else resolve3();
645
970
  });
646
971
  }
647
- send(msg) {
648
- const msgObj = msg;
649
- if (msgObj.type === "test-begin" || msgObj.type === "test-end" || msgObj.type === "run-end") {
650
- this.runEvents.push({
651
- type: msgObj.type,
652
- data: msgObj.data
653
- });
654
- }
655
- const payload = JSON.stringify(msg);
972
+ send(message) {
973
+ const event = message;
974
+ if (event.type === "test-begin" || event.type === "test-end" || event.type === "run-end")
975
+ this.runEvents.push({ type: event.type, data: event.data });
976
+ const payload = JSON.stringify(message);
656
977
  if (!this.isOfflineMode) {
657
- if (this.ws?.readyState === WebSocket.OPEN) {
658
- this.ws.send(payload);
659
- } else {
660
- this.queue.push(payload);
661
- }
978
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(payload);
979
+ else this.queue.push(payload);
662
980
  }
663
981
  }
664
982
  };