@crvy/rprtr 0.0.5 → 0.0.8

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,23 @@ __export(reporter_exports, {
34
34
  default: () => reporter_default
35
35
  });
36
36
  module.exports = __toCommonJS(reporter_exports);
37
+ var import_fs = require("fs");
38
+ var import_promises3 = require("fs/promises");
39
+ var import_path4 = require("path");
40
+ var import_p_limit2 = __toESM(require("p-limit"), 1);
41
+
42
+ // src/debug-log.ts
43
+ var isDebug = () => process.env.DEBUG !== void 0 && process.env.DEBUG !== "";
44
+ var log = (...args) => {
45
+ if (isDebug()) console.log(...args);
46
+ };
47
+ var logError = (...args) => {
48
+ if (isDebug()) console.error(...args);
49
+ };
50
+
51
+ // src/reporter-artifact-ops.ts
37
52
  var import_promises2 = require("fs/promises");
38
- var import_path2 = require("path");
53
+ var import_path3 = require("path");
39
54
  var import_p_limit = __toESM(require("p-limit"), 1);
40
55
 
41
56
  // src/report-artifact.ts
@@ -62,6 +77,12 @@ function withImageSource(image) {
62
77
  source: classifyImage(image)
63
78
  };
64
79
  }
80
+ function getDeclaredVisualNames(visualNames, visualDeclarations) {
81
+ return visualDeclarations?.map(({ visualName }) => visualName) ?? visualNames;
82
+ }
83
+ function copyVisualDeclarations(visualDeclarations) {
84
+ return visualDeclarations?.map((visualDeclaration) => ({ ...visualDeclaration }));
85
+ }
65
86
  function mergeDeclaredImages(images, visualNames) {
66
87
  const names = /* @__PURE__ */ new Set([...Object.keys(images), ...visualNames]);
67
88
  return Object.fromEntries(
@@ -181,10 +202,14 @@ function applyTestEndEvent(state, data, options = {}) {
181
202
  }
182
203
  test.status = mapStatus(data.status);
183
204
  const resultStatus = data.status === "passed" ? "success" : data.status === "failed" ? "failed" : "pending";
205
+ const visualDeclarations = copyVisualDeclarations(data.visualDeclarations);
184
206
  const images = preservePreviousPassingImages(
185
207
  test,
186
208
  data.status,
187
- mergeDeclaredImages(attachmentsToImages(data.attachments, options.screenshotsBaseUrl), data.visualNames)
209
+ mergeDeclaredImages(
210
+ attachmentsToImages(data.attachments, options.screenshotsBaseUrl),
211
+ getDeclaredVisualNames(data.visualNames, visualDeclarations)
212
+ )
188
213
  );
189
214
  const diffCount = countDiffImages(images);
190
215
  const hasDiffs = diffCount > 0;
@@ -196,6 +221,7 @@ function applyTestEndEvent(state, data, options = {}) {
196
221
  status: resultStatus,
197
222
  retries: 0,
198
223
  images,
224
+ visualDeclarations,
199
225
  error: data.error,
200
226
  duration: data.duration
201
227
  }
@@ -233,6 +259,20 @@ var ImagesSchema = import_zod.z.object({
233
259
  error: import_zod.z.string().optional(),
234
260
  source: VisualSourceSchema.optional()
235
261
  });
262
+ var ScreenshotDeclarationSchema = import_zod.z.discriminatedUnion("kind", [
263
+ import_zod.z.object({
264
+ visualName: import_zod.z.string(),
265
+ kind: import_zod.z.literal("named"),
266
+ declaredName: import_zod.z.string(),
267
+ snapshotBaseName: import_zod.z.string(),
268
+ occurrenceIndex: import_zod.z.number()
269
+ }),
270
+ import_zod.z.object({
271
+ visualName: import_zod.z.string(),
272
+ kind: import_zod.z.literal("unnamed"),
273
+ occurrenceIndex: import_zod.z.number()
274
+ })
275
+ ]);
236
276
  var AttachmentSchema = import_zod.z.object({
237
277
  name: import_zod.z.string(),
238
278
  path: import_zod.z.string(),
@@ -244,6 +284,7 @@ var TestResultSchema = import_zod.z.object({
244
284
  status: TestResultStatusSchema,
245
285
  retries: import_zod.z.number(),
246
286
  images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
287
+ visualDeclarations: import_zod.z.array(ScreenshotDeclarationSchema).optional(),
247
288
  error: import_zod.z.string().optional(),
248
289
  duration: import_zod.z.number().optional()
249
290
  });
@@ -290,6 +331,10 @@ var TestEndDataSchema = import_zod.z.object({
290
331
  status: import_zod.z.enum(["passed", "failed", "skipped"]),
291
332
  attachments: import_zod.z.array(AttachmentSchema),
292
333
  visualNames: import_zod.z.array(import_zod.z.string()).default([]),
334
+ visualDeclarations: import_zod.z.preprocess(
335
+ (value) => value === null ? void 0 : value,
336
+ import_zod.z.array(ScreenshotDeclarationSchema).optional()
337
+ ),
293
338
  error: import_zod.z.string().optional(),
294
339
  duration: import_zod.z.number().optional()
295
340
  });
@@ -461,32 +506,285 @@ async function writeReportArtifact(options) {
461
506
  await (0, import_promises.writeFile)(reportHtmlPath, html);
462
507
  }
463
508
 
509
+ // src/snapshot-path-resolver.ts
510
+ var import_crypto = require("crypto");
511
+ var import_path2 = require("path");
512
+ var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
513
+ var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
514
+ function isUnsafeFilePathCharacter(character) {
515
+ const codePoint = character.codePointAt(0);
516
+ return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
517
+ }
518
+ function sanitizeForFilePath(value) {
519
+ return Array.from(value).reduce(
520
+ (state, character) => {
521
+ const unsafeCharacter = isUnsafeFilePathCharacter(character);
522
+ return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
523
+ value: `${state.value}-`,
524
+ previousCharacterWasUnsafe: true
525
+ } : {
526
+ value: `${state.value}${character}`,
527
+ previousCharacterWasUnsafe: false
528
+ };
529
+ },
530
+ {
531
+ value: "",
532
+ previousCharacterWasUnsafe: false
533
+ }
534
+ ).value;
535
+ }
536
+ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
537
+ if (value.length <= length) {
538
+ return value;
539
+ }
540
+ const hash = (0, import_crypto.createHash)("sha1").update(value).digest("hex");
541
+ const middle = `-${hash.slice(0, 5)}-`;
542
+ const start = Math.floor((length - middle.length) / 2);
543
+ const end = length - middle.length - start;
544
+ return value.slice(0, start) + middle + value.slice(-end);
545
+ }
546
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path2.extname)(filePath)) {
547
+ const base = filePath.slice(0, filePath.length - extension.length);
548
+ return sanitizeForFilePath(base) + extension;
549
+ }
550
+ function addSuffixToFilePath(filePath, suffix) {
551
+ const extension = (0, import_path2.extname)(filePath);
552
+ return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
553
+ }
554
+ function normalizedSnapshotDir(config) {
555
+ return (0, import_path2.resolve)(config.configDir, config.snapshotDir);
556
+ }
557
+ function templateValue(template, token, value) {
558
+ return template.replace(
559
+ new RegExp(`\\{(.)?${token}\\}`, "g"),
560
+ (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
561
+ );
562
+ }
563
+ function applyTemplate(input, nameArgument, extension) {
564
+ const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
565
+ const relativeTestFilePath = (0, import_path2.relative)(input.config.testDir, input.testFile);
566
+ const parsed = (0, import_path2.parse)(relativeTestFilePath);
567
+ const tokens = [
568
+ ["testDir", input.config.testDir],
569
+ ["snapshotDir", normalizedSnapshotDir(input.config)],
570
+ ["snapshotSuffix", input.config.snapshotSuffix],
571
+ ["testFileDir", parsed.dir],
572
+ ["platform", process.platform],
573
+ ["projectName", sanitizeForFilePath(input.config.projectName)],
574
+ ["testName", ""],
575
+ ["testFileName", parsed.base],
576
+ ["testFilePath", relativeTestFilePath],
577
+ ["arg", nameArgument],
578
+ ["ext", extension]
579
+ ];
580
+ const snapshotPath = tokens.reduce(
581
+ (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
582
+ template
583
+ );
584
+ return (0, import_path2.resolve)(input.config.configDir, snapshotPath);
585
+ }
586
+ function removeExtension(filePath, extension = (0, import_path2.extname)(filePath)) {
587
+ return filePath.slice(0, filePath.length - extension.length);
588
+ }
589
+ function snapshotNameParts(declaredName) {
590
+ const extension = (0, import_path2.extname)(declaredName) || ".png";
591
+ return {
592
+ extension,
593
+ filePath: (0, import_path2.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
594
+ };
595
+ }
596
+ function filePathForOccurrence(filePath, occurrenceIndex) {
597
+ return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
598
+ }
599
+ function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
600
+ return {
601
+ visualName: declaration.visualName,
602
+ attachmentBaseName: declaration.visualName,
603
+ artifactBaseName: sanitizeForFilePath(declaration.visualName),
604
+ snapshotPath: applyTemplate(input, nameArgument, extension)
605
+ };
606
+ }
607
+ function resolveStringCallTarget(input, declaration) {
608
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
609
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
610
+ const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
611
+ const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
612
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
613
+ }
614
+ function resolveArrayCallTarget(input, declaration) {
615
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
616
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
617
+ const nameArgument = (0, import_path2.join)((0, import_path2.dirname)(occurrenceFilePath), (0, import_path2.basename)(occurrenceFilePath, extension));
618
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
619
+ }
620
+ function resolveNamedTarget(input, declaration) {
621
+ if (!declaration.declaredName.includes("/")) {
622
+ return resolveStringCallTarget(input, declaration);
623
+ }
624
+ const stringCallTarget = resolveStringCallTarget(input, declaration);
625
+ const arrayCallTarget = resolveArrayCallTarget(input, declaration);
626
+ if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
627
+ return stringCallTarget;
628
+ }
629
+ if (input.snapshotPathExists === void 0) {
630
+ return void 0;
631
+ }
632
+ const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
633
+ const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
634
+ if (stringCallTargetExists === arrayCallTargetExists) {
635
+ return void 0;
636
+ }
637
+ return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
638
+ }
639
+ function reporterTitlesWithoutProjectAndFile(reporterTitlePath) {
640
+ return reporterTitlePath.slice(3).filter((part) => part !== "");
641
+ }
642
+ function anonymousName(reporterTitlePath, occurrenceIndex) {
643
+ const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath).join(" ")} ${occurrenceIndex}.png`;
644
+ return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
645
+ }
646
+ function resolveTarget(input, declaration) {
647
+ switch (declaration.kind) {
648
+ case "named":
649
+ return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
650
+ case "unnamed": {
651
+ const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
652
+ const extension = ".png";
653
+ const nameArgument = (0, import_path2.join)((0, import_path2.dirname)(anonymousFileName), (0, import_path2.basename)(anonymousFileName, extension));
654
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
655
+ }
656
+ }
657
+ }
658
+ function resolveBaselineTargets(input) {
659
+ return input.declarations.flatMap((declaration) => {
660
+ const resolvedTarget = resolveTarget(input, declaration);
661
+ return resolvedTarget === void 0 ? [] : [resolvedTarget];
662
+ });
663
+ }
664
+
665
+ // src/reporter-artifact-ops.ts
666
+ var CURRENT_DIRECTORY_ARTIFACT_SEGMENT = "+dot+";
667
+ var PARENT_DIRECTORY_ARTIFACT_SEGMENT = "+dotdot+";
668
+ var MAX_CONCURRENT_FILE_OPS = 5;
669
+ var SAFE_ARTIFACT_CHARACTER = /^[A-Za-z0-9._-]$/;
670
+ function encodeArtifactPathSegment(segment) {
671
+ if (segment === ".") return CURRENT_DIRECTORY_ARTIFACT_SEGMENT;
672
+ if (segment === "..") return PARENT_DIRECTORY_ARTIFACT_SEGMENT;
673
+ return Array.from(
674
+ segment,
675
+ (character) => SAFE_ARTIFACT_CHARACTER.test(character) ? character : encodeURIComponent(character)
676
+ ).join("");
677
+ }
678
+ function safeArtifactPath(name) {
679
+ return name.split("/").map(encodeArtifactPathSegment).join("/");
680
+ }
681
+ function sanitizeId(id) {
682
+ return id.replace(/[^a-zA-Z0-9-_]/g, "_");
683
+ }
684
+ async function copyResolvedBaseline(safeTestId, testScreenshotDir, target, savedAttachments) {
685
+ const attachmentName = `${target.attachmentBaseName}-expected.png`;
686
+ const artifactPath = safeArtifactPath(attachmentName);
687
+ const destPath = (0, import_path3.join)(testScreenshotDir, artifactPath);
688
+ try {
689
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(destPath), { recursive: true });
690
+ await (0, import_promises2.copyFile)(target.snapshotPath, destPath);
691
+ savedAttachments.push({ name: attachmentName, path: `${safeTestId}/${artifactPath}`, contentType: "image/png" });
692
+ log(`[CrvyRprtr] Attached baseline: ${target.snapshotPath}`);
693
+ } catch {
694
+ }
695
+ }
696
+ async function saveAttachments(screenshotDir, testId, result) {
697
+ const savedAttachments = [];
698
+ const safeTestId = sanitizeId(testId);
699
+ const testScreenshotDir = (0, import_path3.join)(screenshotDir, safeTestId);
700
+ const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
701
+ await Promise.all(
702
+ result.attachments.filter(
703
+ (attachment) => attachment.contentType === "image/png" && attachment.path !== void 0
704
+ ).map(
705
+ (attachment) => limit(async () => {
706
+ try {
707
+ const artifactPath = safeArtifactPath(attachment.name);
708
+ const destPath = (0, import_path3.join)(testScreenshotDir, artifactPath);
709
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(destPath), { recursive: true });
710
+ await (0, import_promises2.copyFile)(attachment.path, destPath);
711
+ savedAttachments.push({
712
+ name: attachment.name,
713
+ path: `${safeTestId}/${artifactPath}`,
714
+ contentType: attachment.contentType ?? "image/png"
715
+ });
716
+ log(`[CrvyRprtr] Saved screenshot: ${destPath}`);
717
+ } catch (error) {
718
+ logError(`[CrvyRprtr] Failed to save screenshot: ${attachment.path}`, error);
719
+ savedAttachments.push({
720
+ name: attachment.name,
721
+ path: attachment.path,
722
+ contentType: attachment.contentType ?? "image/png"
723
+ });
724
+ }
725
+ })
726
+ )
727
+ );
728
+ return savedAttachments;
729
+ }
730
+ async function writeOfflineReport(runEvents, offlineReportPath, workerIndex) {
731
+ if (runEvents.length === 0) {
732
+ log("[CrvyRprtr] No offline events to write");
733
+ return;
734
+ }
735
+ try {
736
+ const report = {
737
+ version: 1,
738
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
739
+ workers: workerIndex + 1,
740
+ events: runEvents.map((event) => ({ ...event, timestamp: Date.now(), workerIndex }))
741
+ };
742
+ await (0, import_promises2.writeFile)(offlineReportPath, JSON.stringify(report, null, 2));
743
+ log(`[CrvyRprtr] Wrote offline report: ${offlineReportPath}`);
744
+ } catch (error) {
745
+ logError("[CrvyRprtr] Failed to write offline report:", error);
746
+ }
747
+ }
748
+ async function writeStaticArtifact(runEvents, screenshotDir, reportHtmlPath) {
749
+ try {
750
+ await writeReportArtifact({ events: runEvents, screenshotDir, reportHtmlPath });
751
+ log(`[CrvyRprtr] Wrote report artifact: ${reportHtmlPath}`);
752
+ } catch (error) {
753
+ logError("[CrvyRprtr] Failed to write report artifact:", error);
754
+ }
755
+ }
756
+
464
757
  // src/reporter-utils.ts
465
758
  var NAMED_SCREENSHOT_STEP_TITLE = /toHaveScreenshot\((.+?)\)/;
466
759
  var UNNAMED_SCREENSHOT_STEP_TITLE = /^Expect "toHaveScreenshot"(?:\s|$)/;
467
760
  var SYNTHETIC_SCREENSHOT_PREFIX = "__unnamed-screenshot-";
468
- function normalizeNamedScreenshot(titleMatch) {
761
+ function addVisualSuffix(visualName, occurrenceIndex) {
762
+ if (occurrenceIndex === 1) {
763
+ return visualName;
764
+ }
765
+ const slashIndex = visualName.lastIndexOf("/");
766
+ return slashIndex === -1 ? `${visualName}-${occurrenceIndex - 1}` : `${visualName.slice(0, slashIndex + 1)}${visualName.slice(slashIndex + 1)}-${occurrenceIndex - 1}`;
767
+ }
768
+ function normalizeNamedScreenshot(titleMatch, state) {
469
769
  const unquotedName = titleMatch.trim().replace(/^['"`]|['"`]$/g, "");
470
770
  if (unquotedName === "") {
471
771
  return null;
472
772
  }
473
773
  const normalizedPath = unquotedName.replace(/\\/g, "/");
474
- const normalizedName = normalizedPath.replace(/\.png$/, "");
475
- if (normalizedName === "") {
774
+ const declaredName = normalizedPath.replace(/\.png$/, "");
775
+ if (declaredName === "") {
476
776
  return null;
477
777
  }
778
+ const occurrenceIndex = (state.namedOccurrences.get(declaredName) ?? 0) + 1;
779
+ state.namedOccurrences.set(declaredName, occurrenceIndex);
478
780
  return {
479
- visualName: normalizedName,
480
- snapshotBaseName: normalizedName
781
+ visualName: addVisualSuffix(declaredName, occurrenceIndex),
782
+ kind: "named",
783
+ declaredName,
784
+ snapshotBaseName: declaredName,
785
+ occurrenceIndex
481
786
  };
482
787
  }
483
- function appendDeclaration(state, declaration) {
484
- if (state.seenVisualNames.has(declaration.visualName)) {
485
- return;
486
- }
487
- state.seenVisualNames.add(declaration.visualName);
488
- state.declarations.push(declaration);
489
- }
490
788
  function visitStep(step, state) {
491
789
  const declarationsBeforeChildren = state.declarations.length;
492
790
  for (const nestedStep of step.steps) {
@@ -494,16 +792,19 @@ function visitStep(step, state) {
494
792
  }
495
793
  const namedMatch = step.title.match(NAMED_SCREENSHOT_STEP_TITLE);
496
794
  if (namedMatch?.[1] !== void 0) {
497
- const declaration = normalizeNamedScreenshot(namedMatch[1]);
795
+ const declaration = normalizeNamedScreenshot(namedMatch[1], state);
498
796
  if (declaration !== null) {
499
- appendDeclaration(state, declaration);
797
+ state.declarations.push(declaration);
500
798
  return true;
501
799
  }
502
800
  }
503
801
  const hasNestedScreenshotDeclaration = state.declarations.length > declarationsBeforeChildren;
504
802
  if (UNNAMED_SCREENSHOT_STEP_TITLE.test(step.title) && !hasNestedScreenshotDeclaration) {
505
- appendDeclaration(state, {
506
- visualName: `${SYNTHETIC_SCREENSHOT_PREFIX}${state.nextUnnamedIndex}`
803
+ const occurrenceIndex = state.nextUnnamedIndex;
804
+ state.declarations.push({
805
+ visualName: `${SYNTHETIC_SCREENSHOT_PREFIX}${occurrenceIndex}`,
806
+ kind: "unnamed",
807
+ occurrenceIndex
507
808
  });
508
809
  state.nextUnnamedIndex += 1;
509
810
  return true;
@@ -513,7 +814,7 @@ function visitStep(step, state) {
513
814
  function extractScreenshotDeclarations(steps) {
514
815
  const state = {
515
816
  declarations: [],
516
- seenVisualNames: /* @__PURE__ */ new Set(),
817
+ namedOccurrences: /* @__PURE__ */ new Map(),
517
818
  nextUnnamedIndex: 1
518
819
  };
519
820
  for (const step of steps) {
@@ -523,7 +824,6 @@ function extractScreenshotDeclarations(steps) {
523
824
  }
524
825
 
525
826
  // src/reporter.ts
526
- var MAX_CONCURRENT_FILE_OPS = 5;
527
827
  var CrvyRprtr = class {
528
828
  ws = null;
529
829
  serverUrl;
@@ -532,6 +832,11 @@ var CrvyRprtr = class {
532
832
  workerIndex;
533
833
  offlineReportPath;
534
834
  reportHtmlPath;
835
+ configDir = process.cwd();
836
+ testMetadata = /* @__PURE__ */ new Map();
837
+ playwrightSnapshotDir;
838
+ playwrightSnapshotPathTemplate;
839
+ playwrightToHaveScreenshotPathTemplate;
535
840
  isOfflineMode = false;
536
841
  hadOfflineMode = false;
537
842
  runEvents = [];
@@ -541,228 +846,155 @@ var CrvyRprtr = class {
541
846
  this.workerIndex = parseInt(process.env.TEST_WORKER_INDEX ?? "0", 10) || 0;
542
847
  this.offlineReportPath = options.offlineReportPath ?? `./crvy-rprtr-${this.workerIndex}.json`;
543
848
  this.reportHtmlPath = options.reportHtmlPath ?? "./crvy-rprtr.html";
849
+ this.playwrightSnapshotDir = options.playwrightSnapshotDir;
850
+ this.playwrightSnapshotPathTemplate = options.playwrightSnapshotPathTemplate;
851
+ this.playwrightToHaveScreenshotPathTemplate = options.playwrightToHaveScreenshotPathTemplate;
544
852
  }
545
853
  async onBegin(config, suite) {
546
- console.log(`[CrvyRprtr] Starting run with ${suite.allTests().length} tests`);
547
- await (0, import_promises2.mkdir)(this.screenshotDir, { recursive: true });
854
+ this.configDir = config.configFile === void 0 ? config.rootDir : (0, import_path4.dirname)(config.configFile);
855
+ log(`[CrvyRprtr] Starting run with ${suite.allTests().length} tests`);
856
+ await (0, import_promises3.mkdir)(this.screenshotDir, { recursive: true });
548
857
  this.connect();
549
858
  }
550
859
  connect() {
551
860
  const WebSocketConstructor = globalThis.WebSocket;
552
861
  if (typeof WebSocketConstructor !== "function") {
553
- console.log("[CrvyRprtr] WebSocket unavailable in current runtime; offline mode enabled");
862
+ log("[CrvyRprtr] WebSocket unavailable in current runtime; offline mode enabled");
554
863
  this.enableOfflineMode();
555
864
  return;
556
865
  }
557
866
  try {
558
867
  this.ws = new WebSocketConstructor(this.serverUrl);
559
868
  this.ws.onopen = () => {
560
- console.log("[CrvyRprtr] Connected to Crvy Rprtr server");
869
+ log("[CrvyRprtr] Connected to Crvy Rprtr server");
561
870
  this.isOfflineMode = false;
562
- for (const msg of this.queue) this.ws.send(msg);
871
+ for (const message of this.queue) this.ws.send(message);
563
872
  this.queue = [];
564
873
  };
565
874
  this.ws.onerror = (error) => {
566
- console.error("[CrvyRprtr] WebSocket error:", error);
875
+ logError("[CrvyRprtr] WebSocket error:", error);
567
876
  this.enableOfflineMode();
568
877
  };
569
878
  this.ws.onclose = () => {
570
- console.log("[CrvyRprtr] Disconnected from Crvy Rprtr server");
879
+ log("[CrvyRprtr] Disconnected from Crvy Rprtr server");
571
880
  this.enableOfflineMode();
572
881
  };
573
- } catch (e) {
574
- console.error("[CrvyRprtr] Failed to connect:", e);
882
+ } catch (error) {
883
+ logError("[CrvyRprtr] Failed to connect:", error);
575
884
  this.enableOfflineMode();
576
885
  }
577
886
  }
578
887
  enableOfflineMode() {
579
- if (!this.isOfflineMode) {
580
- this.isOfflineMode = true;
581
- this.hadOfflineMode = true;
582
- console.log("[CrvyRprtr] Offline mode enabled - events will be queued to file");
583
- }
888
+ if (this.isOfflineMode) return;
889
+ this.isOfflineMode = this.hadOfflineMode = true;
890
+ log("[CrvyRprtr] Offline mode enabled - events will be queued to file");
584
891
  }
585
- onTestBegin(test) {
892
+ describeTitlePath(test) {
586
893
  const titlePath = [];
587
- let suite = test.parent;
588
- while (suite && suite.type === "describe") {
894
+ for (let suite = test.parent; suite?.type === "describe"; suite = suite.parent)
589
895
  titlePath.unshift(suite.title);
590
- suite = suite.parent;
591
- }
896
+ return titlePath;
897
+ }
898
+ reporterTitlePath(test) {
899
+ return typeof test.titlePath === "function" ? test.titlePath() : ["", test.parent.project()?.name ?? "chromium", test.location.file, ...this.describeTitlePath(test), test.title];
900
+ }
901
+ onTestBegin(test) {
902
+ this.testMetadata.set(test.id, { reporterTitlePath: this.reporterTitlePath(test) });
592
903
  this.send({
593
904
  type: "test-begin",
594
905
  data: {
595
906
  id: test.id,
596
907
  title: test.title,
597
- titlePath,
908
+ titlePath: this.describeTitlePath(test),
598
909
  browser: test.parent.project()?.name ?? "chromium",
599
- location: {
600
- file: test.location.file,
601
- line: test.location.line
602
- }
910
+ location: { file: test.location.file, line: test.location.line }
603
911
  }
604
912
  });
605
913
  }
606
914
  async onTestEnd(test, result) {
607
915
  const screenshotDeclarations = extractScreenshotDeclarations(result.steps);
608
- const visualNames = screenshotDeclarations.map(({ visualName }) => visualName);
609
- const savedAttachments = await this.saveAttachments(test.id, result);
610
- await this.copySnapshotBaselines(test, result.status, screenshotDeclarations, savedAttachments);
611
- this.send({
612
- type: "test-end",
613
- data: {
614
- id: test.id,
615
- title: test.title,
616
- status: result.status,
617
- attachments: savedAttachments,
618
- visualNames,
619
- error: result.errors.length > 0 ? result.errors[0]?.message : void 0,
620
- duration: result.duration
621
- }
622
- });
623
- }
624
- async copySnapshotBaselines(test, status, screenshotDeclarations, savedAttachments) {
625
- if (status !== "passed") return;
626
- const namedDeclarations = screenshotDeclarations.filter(
627
- (declaration) => declaration.snapshotBaseName !== void 0
628
- );
629
- if (namedDeclarations.length === 0) return;
630
- const projectName = test.parent.project()?.name;
631
- const snapshotDir = `${test.location.file}-snapshots`;
632
- const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(test.id));
633
- const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
634
- const copyPromises = namedDeclarations.map(
635
- ({ visualName, snapshotBaseName }) => limit(async () => {
636
- const destName = `${visualName}-expected.png`;
637
- const destPath = (0, import_path2.join)(testScreenshotDir, destName);
638
- const snapshotPath = projectName === "" ? (0, import_path2.join)(snapshotDir, `${snapshotBaseName}-${process.platform}.png`) : (0, import_path2.join)(snapshotDir, `${snapshotBaseName}-${projectName ?? "chromium"}-${process.platform}.png`);
639
- try {
640
- await (0, import_promises2.mkdir)((0, import_path2.dirname)(destPath), { recursive: true });
641
- await (0, import_promises2.copyFile)(snapshotPath, destPath);
642
- savedAttachments.push({
643
- name: destName,
644
- path: `${this.sanitizeId(test.id)}/${destName}`,
645
- contentType: "image/png"
646
- });
647
- console.log(`[CrvyRprtr] Attached baseline: ${snapshotPath}`);
648
- } catch {
649
- }
650
- })
651
- );
652
- await Promise.all(copyPromises);
653
- }
654
- async saveAttachments(testId, result) {
655
- const savedAttachments = [];
656
- const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(testId));
657
- const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
658
- const attachmentPromises = result.attachments.filter(
659
- (attachment) => attachment.contentType === "image/png" && attachment.path !== void 0
660
- ).map(
661
- (attachment) => limit(async () => {
662
- try {
663
- await (0, import_promises2.mkdir)(testScreenshotDir, { recursive: true });
664
- const fileName = attachment.name;
665
- const destPath = (0, import_path2.join)(testScreenshotDir, fileName);
666
- await (0, import_promises2.copyFile)(attachment.path, destPath);
667
- const attachmentData = {
668
- name: attachment.name,
669
- path: `${this.sanitizeId(testId)}/${fileName}`,
670
- contentType: attachment.contentType
671
- };
672
- savedAttachments.push(attachmentData);
673
- console.log(`[CrvyRprtr] Saved screenshot: ${destPath}`);
674
- } catch (e) {
675
- console.error(`[CrvyRprtr] Failed to save screenshot: ${attachment.path}`, e);
676
- const fallbackData = {
677
- name: attachment.name,
678
- path: attachment.path,
679
- contentType: attachment.contentType
680
- };
681
- savedAttachments.push(fallbackData);
682
- }
683
- })
684
- );
685
- await Promise.all(attachmentPromises);
686
- return savedAttachments;
687
- }
688
- sanitizeId(id) {
689
- return id.replace(/[^a-zA-Z0-9-_]/g, "_");
690
- }
691
- async writeOfflineReport() {
692
- if (this.runEvents.length === 0) {
693
- console.log("[CrvyRprtr] No offline events to write");
694
- return;
695
- }
916
+ const savedAttachments = await saveAttachments(this.screenshotDir, test.id, result);
696
917
  try {
697
- const report = {
698
- version: 1,
699
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
700
- workers: this.workerIndex + 1,
701
- events: this.runEvents.map((e) => ({
702
- ...e,
703
- timestamp: Date.now(),
704
- workerIndex: this.workerIndex
705
- }))
706
- };
707
- await (0, import_promises2.writeFile)(this.offlineReportPath, JSON.stringify(report, null, 2));
708
- console.log(`[CrvyRprtr] Wrote offline report: ${this.offlineReportPath}`);
709
- } catch (e) {
710
- console.error("[CrvyRprtr] Failed to write offline report:", e);
711
- }
712
- }
713
- async writeStaticArtifact() {
714
- try {
715
- await writeReportArtifact({
716
- events: this.runEvents,
717
- screenshotDir: this.screenshotDir,
718
- reportHtmlPath: this.reportHtmlPath
918
+ await this.copySnapshotBaselines(test, result.status, screenshotDeclarations, savedAttachments);
919
+ this.send({
920
+ type: "test-end",
921
+ data: {
922
+ id: test.id,
923
+ title: test.title,
924
+ status: result.status,
925
+ attachments: savedAttachments,
926
+ visualNames: screenshotDeclarations.map(({ visualName }) => visualName),
927
+ visualDeclarations: screenshotDeclarations,
928
+ error: result.errors.length > 0 ? result.errors[0]?.message : void 0,
929
+ duration: result.duration
930
+ }
719
931
  });
720
- console.log(`[CrvyRprtr] Wrote report artifact: ${this.reportHtmlPath}`);
721
- } catch (e) {
722
- console.error("[CrvyRprtr] Failed to write report artifact:", e);
932
+ } finally {
933
+ this.testMetadata.delete(test.id);
723
934
  }
724
935
  }
936
+ baselineInput(test, shots) {
937
+ const project = test.parent.project();
938
+ const snapshotDir = this.playwrightSnapshotDir ?? project?.snapshotDir;
939
+ if (project === void 0 || typeof project.testDir !== "string" || typeof snapshotDir !== "string") return null;
940
+ return {
941
+ testFile: test.location.file,
942
+ reporterTitlePath: this.testMetadata.get(test.id)?.reporterTitlePath ?? this.reporterTitlePath(test),
943
+ declarations: shots,
944
+ config: {
945
+ configDir: this.configDir,
946
+ testDir: project.testDir,
947
+ snapshotDir,
948
+ projectName: project.name,
949
+ snapshotSuffix: process.platform,
950
+ snapshotPathTemplate: this.playwrightSnapshotPathTemplate,
951
+ toHaveScreenshotPathTemplate: this.playwrightToHaveScreenshotPathTemplate
952
+ },
953
+ snapshotPathExists: import_fs.existsSync
954
+ };
955
+ }
956
+ async copySnapshotBaselines(test, status, screenshotDeclarations, savedAttachments) {
957
+ if (status !== "passed" || screenshotDeclarations.length === 0) return;
958
+ const input = this.baselineInput(test, screenshotDeclarations);
959
+ if (input === null) return;
960
+ const targets = resolveBaselineTargets(input);
961
+ if (targets.length === 0) return;
962
+ const safeTestId = sanitizeId(test.id);
963
+ const testScreenshotDir = (0, import_path4.join)(this.screenshotDir, safeTestId);
964
+ const limit = (0, import_p_limit2.default)(5);
965
+ await Promise.all(
966
+ targets.map(
967
+ (target) => limit(() => copyResolvedBaseline(safeTestId, testScreenshotDir, target, savedAttachments))
968
+ )
969
+ );
970
+ }
725
971
  async onEnd(result) {
726
- this.send({
727
- type: "run-end",
728
- data: {
729
- status: result.status
730
- }
731
- });
732
- await this.writeStaticArtifact();
733
- if (this.hadOfflineMode) {
734
- await this.writeOfflineReport();
735
- }
736
- await new Promise((resolve2) => {
972
+ this.send({ type: "run-end", data: { status: result.status } });
973
+ await writeStaticArtifact(this.runEvents, this.screenshotDir, this.reportHtmlPath);
974
+ if (this.hadOfflineMode) await writeOfflineReport(this.runEvents, this.offlineReportPath, this.workerIndex);
975
+ await new Promise((resolve3) => {
737
976
  if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
738
- resolve2();
977
+ resolve3();
739
978
  return;
740
979
  }
741
980
  this.ws.onclose = () => {
742
- resolve2();
981
+ resolve3();
743
982
  };
744
983
  setTimeout(() => {
745
984
  this.ws?.close();
746
- resolve2();
985
+ resolve3();
747
986
  }, 1e3);
748
987
  this.ws.close();
749
988
  });
750
989
  }
751
- send(msg) {
752
- const msgObj = msg;
753
- if (msgObj.type === "test-begin" || msgObj.type === "test-end" || msgObj.type === "run-end") {
754
- this.runEvents.push({
755
- type: msgObj.type,
756
- data: msgObj.data
757
- });
758
- }
759
- const payload = JSON.stringify(msg);
990
+ send(message) {
991
+ const event = message;
992
+ if (event.type === "test-begin" || event.type === "test-end" || event.type === "run-end")
993
+ this.runEvents.push({ type: event.type, data: event.data });
994
+ const payload = JSON.stringify(message);
760
995
  if (!this.isOfflineMode) {
761
- if (this.ws?.readyState === WebSocket.OPEN) {
762
- this.ws.send(payload);
763
- } else {
764
- this.queue.push(payload);
765
- }
996
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(payload);
997
+ else this.queue.push(payload);
766
998
  }
767
999
  }
768
1000
  };