@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/server.cjs CHANGED
@@ -35,7 +35,7 @@ __export(server_exports, {
35
35
  module.exports = __toCommonJS(server_exports);
36
36
 
37
37
  // src/server/app.ts
38
- var import_path4 = require("path");
38
+ var import_path6 = require("path");
39
39
  var import_url = require("url");
40
40
  var import_p_limit = __toESM(require("p-limit"), 1);
41
41
 
@@ -47,6 +47,36 @@ var import_path = require("path");
47
47
  function normalizeScreenshotsBaseUrl(baseUrl) {
48
48
  return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
49
49
  }
50
+ function classifyImage(image) {
51
+ if (image.actual !== void 0 || image.diff !== void 0) {
52
+ return "comparison";
53
+ }
54
+ if (image.expect !== void 0) {
55
+ return "baseline-only";
56
+ }
57
+ return "declared-only";
58
+ }
59
+ function withImageSource(image) {
60
+ return {
61
+ ...image,
62
+ source: classifyImage(image)
63
+ };
64
+ }
65
+ function getDeclaredVisualNames(visualNames, visualDeclarations) {
66
+ return visualDeclarations?.map(({ visualName }) => visualName) ?? visualNames;
67
+ }
68
+ function copyVisualDeclarations(visualDeclarations) {
69
+ return visualDeclarations?.map((visualDeclaration) => ({ ...visualDeclaration }));
70
+ }
71
+ function mergeDeclaredImages(images, visualNames) {
72
+ const names = /* @__PURE__ */ new Set([...Object.keys(images), ...visualNames]);
73
+ return Object.fromEntries(
74
+ Array.from(names, (name) => {
75
+ const current = images[name] ?? {};
76
+ return [name, withImageSource(current)];
77
+ })
78
+ );
79
+ }
50
80
  function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/") {
51
81
  const images = {};
52
82
  const baseUrl = normalizeScreenshotsBaseUrl(screenshotsBaseUrl);
@@ -57,7 +87,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
57
87
  const baseName = match[1];
58
88
  const role = match[2];
59
89
  if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
60
- images[baseName] ??= { actual: "" };
90
+ images[baseName] ??= {};
61
91
  const url = `${baseUrl}${attachment.path}`;
62
92
  const img = images[baseName];
63
93
  if (img !== null && img !== void 0) {
@@ -68,10 +98,11 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
68
98
  }
69
99
  for (const key of Object.keys(images)) {
70
100
  const img = images[key];
71
- if (img?.actual !== null && img?.actual !== void 0 && img?.expect !== null && img?.expect !== void 0 && img?.diff === void 0)
101
+ if (img?.actual !== void 0 && img?.expect !== void 0 && img?.diff === void 0) {
72
102
  delete img.expect;
103
+ }
73
104
  }
74
- return images;
105
+ return mergeDeclaredImages(images, []);
75
106
  }
76
107
  function mapStatus(status) {
77
108
  switch (status) {
@@ -87,17 +118,39 @@ function mapStatus(status) {
87
118
  }
88
119
 
89
120
  // 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
- );
121
+ function isCurrentArtifact(image) {
122
+ return image?.actual !== void 0 || image?.expect !== void 0 || image?.diff !== void 0;
123
+ }
124
+ function isReusablePassingImage(image) {
125
+ const source = image.source ?? classifyImage(image);
126
+ if (source === "comparison") {
127
+ return image.actual !== void 0 && image.diff === void 0;
128
+ }
129
+ return source === "baseline-only";
130
+ }
131
+ function hasReusablePassingImages(images) {
132
+ return Object.values(images).some((img) => img !== null && img !== void 0 && isReusablePassingImage(img));
94
133
  }
95
134
  function preservePreviousPassingImages(test, status, images) {
96
- if (status !== "passed" || Object.keys(images).length > 0) {
135
+ if (status !== "passed") {
97
136
  return images;
98
137
  }
99
138
  const previousImages = test.results?.[0]?.images ?? {};
100
- return hasReviewablePassingImages(previousImages) ? previousImages : images;
139
+ if (!hasReusablePassingImages(previousImages)) {
140
+ return images;
141
+ }
142
+ return Object.entries(previousImages).reduce((currentImages, [name, previousImage]) => {
143
+ if (!Object.hasOwn(currentImages, name) || previousImage === void 0 || !isReusablePassingImage(previousImage) || isCurrentArtifact(currentImages[name])) {
144
+ return currentImages;
145
+ }
146
+ return {
147
+ ...currentImages,
148
+ [name]: {
149
+ ...previousImage,
150
+ source: previousImage.source ?? classifyImage(previousImage)
151
+ }
152
+ };
153
+ }, images);
101
154
  }
102
155
  function countDiffImages(images) {
103
156
  return Object.values(images).filter((img) => img?.diff !== null && img?.diff !== void 0).length;
@@ -133,10 +186,15 @@ function applyTestEndEvent(state, data, options = {}) {
133
186
  return null;
134
187
  }
135
188
  test.status = mapStatus(data.status);
189
+ const resultStatus = data.status === "passed" ? "success" : data.status === "failed" ? "failed" : "pending";
190
+ const visualDeclarations = copyVisualDeclarations(data.visualDeclarations);
136
191
  const images = preservePreviousPassingImages(
137
192
  test,
138
193
  data.status,
139
- attachmentsToImages(data.attachments, options.screenshotsBaseUrl)
194
+ mergeDeclaredImages(
195
+ attachmentsToImages(data.attachments, options.screenshotsBaseUrl),
196
+ getDeclaredVisualNames(data.visualNames, visualDeclarations)
197
+ )
140
198
  );
141
199
  const diffCount = countDiffImages(images);
142
200
  const hasDiffs = diffCount > 0;
@@ -145,9 +203,10 @@ function applyTestEndEvent(state, data, options = {}) {
145
203
  }
146
204
  test.results = [
147
205
  {
148
- status: data.status === "passed" ? "success" : "failed",
206
+ status: resultStatus,
149
207
  retries: 0,
150
208
  images,
209
+ visualDeclarations,
151
210
  error: data.error,
152
211
  duration: data.duration
153
212
  }
@@ -177,23 +236,40 @@ var LocationSchema = import_zod.z.object({
177
236
  file: import_zod.z.string(),
178
237
  line: import_zod.z.number()
179
238
  });
239
+ var VisualSourceSchema = import_zod.z.enum(["comparison", "baseline-only", "declared-only"]);
180
240
  var ImagesSchema = import_zod.z.object({
181
- actual: import_zod.z.string(),
241
+ actual: import_zod.z.string().optional(),
182
242
  expect: import_zod.z.string().optional(),
183
243
  diff: import_zod.z.string().optional(),
184
- error: import_zod.z.string().optional()
244
+ error: import_zod.z.string().optional(),
245
+ source: VisualSourceSchema.optional()
185
246
  });
247
+ var ScreenshotDeclarationSchema = import_zod.z.discriminatedUnion("kind", [
248
+ import_zod.z.object({
249
+ visualName: import_zod.z.string(),
250
+ kind: import_zod.z.literal("named"),
251
+ declaredName: import_zod.z.string(),
252
+ snapshotBaseName: import_zod.z.string(),
253
+ occurrenceIndex: import_zod.z.number()
254
+ }),
255
+ import_zod.z.object({
256
+ visualName: import_zod.z.string(),
257
+ kind: import_zod.z.literal("unnamed"),
258
+ occurrenceIndex: import_zod.z.number()
259
+ })
260
+ ]);
186
261
  var AttachmentSchema = import_zod.z.object({
187
262
  name: import_zod.z.string(),
188
263
  path: import_zod.z.string(),
189
264
  contentType: import_zod.z.string()
190
265
  });
191
266
  var TestStatusSchema = import_zod.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
267
+ var TestResultStatusSchema = import_zod.z.enum(["failed", "success", "pending"]);
192
268
  var TestResultSchema = import_zod.z.object({
193
- status: import_zod.z.enum(["failed", "success"]),
269
+ status: TestResultStatusSchema,
194
270
  retries: import_zod.z.number(),
195
- // eslint-disable-next-line typescript/no-unsafe-type-assertion
196
271
  images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
272
+ visualDeclarations: import_zod.z.array(ScreenshotDeclarationSchema).optional(),
197
273
  error: import_zod.z.string().optional(),
198
274
  duration: import_zod.z.number().optional()
199
275
  });
@@ -221,7 +297,6 @@ var CrvyRprtrSuiteSchema = import_zod.z.lazy(
221
297
  opened: import_zod.z.boolean(),
222
298
  checked: import_zod.z.boolean(),
223
299
  indeterminate: import_zod.z.boolean(),
224
- // eslint-disable-next-line typescript/no-unsafe-type-assertion
225
300
  children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
226
301
  })
227
302
  );
@@ -240,6 +315,11 @@ var TestEndDataSchema = import_zod.z.object({
240
315
  id: import_zod.z.string(),
241
316
  status: import_zod.z.enum(["passed", "failed", "skipped"]),
242
317
  attachments: import_zod.z.array(AttachmentSchema),
318
+ visualNames: import_zod.z.array(import_zod.z.string()).default([]),
319
+ visualDeclarations: import_zod.z.preprocess(
320
+ (value) => value === null ? void 0 : value,
321
+ import_zod.z.array(ScreenshotDeclarationSchema).optional()
322
+ ),
243
323
  error: import_zod.z.string().optional(),
244
324
  duration: import_zod.z.number().optional()
245
325
  });
@@ -480,14 +560,281 @@ function handleSync(ctx) {
480
560
  broadcastToBrowsers(ctx.wsClients, { type: "sync", data: ctx.reportData });
481
561
  }
482
562
 
483
- // src/server/routes.ts
563
+ // src/server/report-watch.ts
564
+ var import_promises3 = require("fs/promises");
484
565
  var import_path3 = require("path");
566
+ var OFFLINE_REPORT_FILE_PATTERN2 = /^crvy-rprtr(?:-\d+)?\.json$/;
567
+ function createDebouncedRefresh(reload, delayMs = 50) {
568
+ let timer = null;
569
+ return () => {
570
+ if (timer !== null) {
571
+ clearTimeout(timer);
572
+ }
573
+ timer = setTimeout(() => {
574
+ timer = null;
575
+ void reload();
576
+ }, delayMs);
577
+ };
578
+ }
579
+ function isFileNotFound2(error) {
580
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
581
+ }
582
+ async function describeFile(filePath, label) {
583
+ try {
584
+ const fileStats = await (0, import_promises3.stat)(filePath);
585
+ return `${label}:${fileStats.size}:${fileStats.mtimeMs}`;
586
+ } catch (error) {
587
+ if (isFileNotFound2(error)) {
588
+ return null;
589
+ }
590
+ throw error;
591
+ }
592
+ }
593
+ async function createOfflineFingerprint(offlineReportDir) {
594
+ if (!await isDirectory(offlineReportDir)) {
595
+ return "offline:missing";
596
+ }
597
+ try {
598
+ const entries = await (0, import_promises3.readdir)(offlineReportDir, { withFileTypes: true });
599
+ const relevantEntries = entries.filter(
600
+ (entry) => entry.isFile() && (entry.name === "report.json" || OFFLINE_REPORT_FILE_PATTERN2.test(entry.name))
601
+ ).sort((left, right) => left.name.localeCompare(right.name));
602
+ const parts = await Promise.all(
603
+ relevantEntries.map((entry) => describeFile((0, import_path3.join)(offlineReportDir, entry.name), entry.name))
604
+ );
605
+ return `offline:${parts.filter((part) => part !== null).join("|")}`;
606
+ } catch (error) {
607
+ const errorMsg = error instanceof Error ? error.message : String(error);
608
+ console.warn(`[Server] Unable to scan ${offlineReportDir}: ${errorMsg}`);
609
+ return "offline:error";
610
+ }
611
+ }
612
+ async function createScreenshotFingerprint(screenshotDir, relativePath = "") {
613
+ const directoryPath = relativePath === "" ? screenshotDir : (0, import_path3.join)(screenshotDir, relativePath);
614
+ if (!await isDirectory(directoryPath)) {
615
+ return relativePath === "" ? ["screenshots:missing"] : [];
616
+ }
617
+ try {
618
+ const entries = await (0, import_promises3.readdir)(directoryPath, { withFileTypes: true });
619
+ const parts = await Promise.all(
620
+ entries.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
621
+ const entryRelativePath = relativePath === "" ? entry.name : (0, import_path3.join)(relativePath, entry.name);
622
+ if (entry.isDirectory()) {
623
+ return createScreenshotFingerprint(screenshotDir, entryRelativePath);
624
+ }
625
+ return describeFile((0, import_path3.join)(screenshotDir, entryRelativePath), entryRelativePath);
626
+ })
627
+ );
628
+ return parts.flat().filter((part) => part !== null);
629
+ } catch (error) {
630
+ if (isFileNotFound2(error)) {
631
+ return relativePath === "" ? ["screenshots:missing"] : [];
632
+ }
633
+ const errorMsg = error instanceof Error ? error.message : String(error);
634
+ console.warn(`[Server] Unable to scan ${directoryPath}: ${errorMsg}`);
635
+ return relativePath === "" ? ["screenshots:error"] : [];
636
+ }
637
+ }
638
+ async function createArtifactsFingerprint(options) {
639
+ const [offlineFingerprint, screenshotFingerprint] = await Promise.all([
640
+ createOfflineFingerprint(options.offlineReportDir),
641
+ createScreenshotFingerprint(options.screenshotDir)
642
+ ]);
643
+ return `${offlineFingerprint}::${screenshotFingerprint.join("|")}`;
644
+ }
645
+ async function watchReportArtifacts(options) {
646
+ let fingerprint = await createArtifactsFingerprint(options);
647
+ let isPolling = false;
648
+ const interval = setInterval(() => {
649
+ if (isPolling) {
650
+ return;
651
+ }
652
+ isPolling = true;
653
+ void createArtifactsFingerprint(options).then((nextFingerprint) => {
654
+ if (nextFingerprint === fingerprint) {
655
+ return;
656
+ }
657
+ fingerprint = nextFingerprint;
658
+ options.scheduleRefresh();
659
+ }).catch((error) => {
660
+ const errorMsg = error instanceof Error ? error.message : String(error);
661
+ console.warn(`[Server] Artifact refresh poll failed: ${errorMsg}`);
662
+ }).finally(() => {
663
+ isPolling = false;
664
+ });
665
+ }, 100);
666
+ return () => {
667
+ clearInterval(interval);
668
+ };
669
+ }
670
+
671
+ // src/server/routes.ts
672
+ var import_fs = require("fs");
673
+ var import_path5 = require("path");
674
+
675
+ // src/snapshot-path-resolver.ts
676
+ var import_crypto = require("crypto");
677
+ var import_path4 = require("path");
678
+ var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
679
+ var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
680
+ function isUnsafeFilePathCharacter(character) {
681
+ const codePoint = character.codePointAt(0);
682
+ return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
683
+ }
684
+ function sanitizeForFilePath(value) {
685
+ return Array.from(value).reduce(
686
+ (state, character) => {
687
+ const unsafeCharacter = isUnsafeFilePathCharacter(character);
688
+ return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
689
+ value: `${state.value}-`,
690
+ previousCharacterWasUnsafe: true
691
+ } : {
692
+ value: `${state.value}${character}`,
693
+ previousCharacterWasUnsafe: false
694
+ };
695
+ },
696
+ {
697
+ value: "",
698
+ previousCharacterWasUnsafe: false
699
+ }
700
+ ).value;
701
+ }
702
+ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
703
+ if (value.length <= length) {
704
+ return value;
705
+ }
706
+ const hash = (0, import_crypto.createHash)("sha1").update(value).digest("hex");
707
+ const middle = `-${hash.slice(0, 5)}-`;
708
+ const start = Math.floor((length - middle.length) / 2);
709
+ const end = length - middle.length - start;
710
+ return value.slice(0, start) + middle + value.slice(-end);
711
+ }
712
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
713
+ const base = filePath.slice(0, filePath.length - extension.length);
714
+ return sanitizeForFilePath(base) + extension;
715
+ }
716
+ function addSuffixToFilePath(filePath, suffix) {
717
+ const extension = (0, import_path4.extname)(filePath);
718
+ return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
719
+ }
720
+ function normalizedSnapshotDir(config) {
721
+ return (0, import_path4.resolve)(config.configDir, config.snapshotDir);
722
+ }
723
+ function templateValue(template, token, value) {
724
+ return template.replace(
725
+ new RegExp(`\\{(.)?${token}\\}`, "g"),
726
+ (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
727
+ );
728
+ }
729
+ function applyTemplate(input, nameArgument, extension) {
730
+ const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
731
+ const relativeTestFilePath = (0, import_path4.relative)(input.config.testDir, input.testFile);
732
+ const parsed = (0, import_path4.parse)(relativeTestFilePath);
733
+ const tokens = [
734
+ ["testDir", input.config.testDir],
735
+ ["snapshotDir", normalizedSnapshotDir(input.config)],
736
+ ["snapshotSuffix", input.config.snapshotSuffix],
737
+ ["testFileDir", parsed.dir],
738
+ ["platform", process.platform],
739
+ ["projectName", sanitizeForFilePath(input.config.projectName)],
740
+ ["testName", ""],
741
+ ["testFileName", parsed.base],
742
+ ["testFilePath", relativeTestFilePath],
743
+ ["arg", nameArgument],
744
+ ["ext", extension]
745
+ ];
746
+ const snapshotPath = tokens.reduce(
747
+ (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
748
+ template
749
+ );
750
+ return (0, import_path4.resolve)(input.config.configDir, snapshotPath);
751
+ }
752
+ function removeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
753
+ return filePath.slice(0, filePath.length - extension.length);
754
+ }
755
+ function snapshotNameParts(declaredName) {
756
+ const extension = (0, import_path4.extname)(declaredName) || ".png";
757
+ return {
758
+ extension,
759
+ filePath: (0, import_path4.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
760
+ };
761
+ }
762
+ function filePathForOccurrence(filePath, occurrenceIndex) {
763
+ return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
764
+ }
765
+ function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
766
+ return {
767
+ visualName: declaration.visualName,
768
+ attachmentBaseName: declaration.visualName,
769
+ artifactBaseName: sanitizeForFilePath(declaration.visualName),
770
+ snapshotPath: applyTemplate(input, nameArgument, extension)
771
+ };
772
+ }
773
+ function resolveStringCallTarget(input, declaration) {
774
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
775
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
776
+ const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
777
+ const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
778
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
779
+ }
780
+ function resolveArrayCallTarget(input, declaration) {
781
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
782
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
783
+ const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(occurrenceFilePath), (0, import_path4.basename)(occurrenceFilePath, extension));
784
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
785
+ }
786
+ function resolveNamedTarget(input, declaration) {
787
+ if (!declaration.declaredName.includes("/")) {
788
+ return resolveStringCallTarget(input, declaration);
789
+ }
790
+ const stringCallTarget = resolveStringCallTarget(input, declaration);
791
+ const arrayCallTarget = resolveArrayCallTarget(input, declaration);
792
+ if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
793
+ return stringCallTarget;
794
+ }
795
+ if (input.snapshotPathExists === void 0) {
796
+ return void 0;
797
+ }
798
+ const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
799
+ const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
800
+ if (stringCallTargetExists === arrayCallTargetExists) {
801
+ return void 0;
802
+ }
803
+ return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
804
+ }
805
+ function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
806
+ return reporterTitlePath2.slice(3).filter((part) => part !== "");
807
+ }
808
+ function anonymousName(reporterTitlePath2, occurrenceIndex) {
809
+ const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath2).join(" ")} ${occurrenceIndex}.png`;
810
+ return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
811
+ }
812
+ function resolveTarget(input, declaration) {
813
+ switch (declaration.kind) {
814
+ case "named":
815
+ return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
816
+ case "unnamed": {
817
+ const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
818
+ const extension = ".png";
819
+ const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(anonymousFileName), (0, import_path4.basename)(anonymousFileName, extension));
820
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
821
+ }
822
+ }
823
+ }
824
+ function resolveBaselineTargets(input) {
825
+ return input.declarations.flatMap((declaration) => {
826
+ const resolvedTarget = resolveTarget(input, declaration);
827
+ return resolvedTarget === void 0 ? [] : [resolvedTarget];
828
+ });
829
+ }
830
+
831
+ // src/server/routes.ts
485
832
  var LIVE_UPDATES_WEBSOCKET_PATH = "/";
486
833
  function isWebSocketUpgradeRequest(req) {
487
834
  return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
488
835
  }
489
836
  async function handleRoot(ctx) {
490
- const html = await respondWithFile((0, import_path3.join)(ctx.staticDir, "index.html"), "text/html");
837
+ const html = await respondWithFile((0, import_path5.join)(ctx.staticDir, "index.html"), "text/html");
491
838
  return html ?? new Response("Not Found", { status: 404 });
492
839
  }
493
840
  async function handleAppCss() {
@@ -504,6 +851,37 @@ async function handleSrcFiles(req) {
504
851
  function handleApiReport(ctx) {
505
852
  return Response.json(ctx.reportData);
506
853
  }
854
+ var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
855
+ function actualPathFromUrl(ctx, actualUrl) {
856
+ return actualUrl.startsWith("/screenshots/") ? (0, import_path5.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length)) : actualUrl;
857
+ }
858
+ function reporterTitlePath(test) {
859
+ const testFile = test.location?.file;
860
+ return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
861
+ }
862
+ function resolveApprovalTarget(ctx, test, retry, imageName) {
863
+ const testFile = test.location?.file;
864
+ const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
865
+ if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
866
+ return null;
867
+ }
868
+ const targets = resolveBaselineTargets({
869
+ testFile,
870
+ reporterTitlePath: reporterTitlePath(test),
871
+ declarations: [declaration],
872
+ config: {
873
+ configDir: ctx.approvalRouting.configDir,
874
+ testDir: ctx.approvalRouting.playwrightTestDir ?? (0, import_path5.dirname)(testFile),
875
+ snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? (0, import_path5.dirname)(testFile),
876
+ projectName: test.browser,
877
+ snapshotSuffix: process.platform,
878
+ snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
879
+ toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
880
+ },
881
+ snapshotPathExists: import_fs.existsSync
882
+ });
883
+ return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
884
+ }
507
885
  async function handleApiApprove(ctx, req) {
508
886
  try {
509
887
  const rawBody = await req.json();
@@ -514,61 +892,96 @@ async function handleApiApprove(ctx, req) {
514
892
  }
515
893
  const { id, retry, image } = parsed;
516
894
  const test = ctx.reportData.tests[id];
517
- if (test !== null && test !== void 0) {
518
- test.approved ??= {};
519
- test.approved[image] = retry;
895
+ if (test === void 0) {
896
+ return Response.json({ success: false, error: "Test not found" }, { status: 404 });
897
+ }
898
+ const actualUrl = test.results?.[retry]?.images?.[image]?.actual;
899
+ if (actualUrl === void 0) {
900
+ return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
901
+ }
902
+ const snapshotPath = resolveApprovalTarget(ctx, test, retry, image);
903
+ if (snapshotPath === null) {
904
+ return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
905
+ }
906
+ try {
907
+ await copyFilePortable(actualPathFromUrl(ctx, actualUrl), snapshotPath);
908
+ test.approved = { ...test.approved ?? {}, [image]: retry };
520
909
  await ctx.saveReport();
521
- const actualUrl = test.results?.[retry]?.images?.[image]?.actual;
522
- if (actualUrl !== null && actualUrl !== void 0 && test.location?.file !== null && test.location?.file !== void 0) {
523
- const actualPath = actualUrl.replace("/screenshots/", `${ctx.reportData.screenshotDir}/`);
524
- const snapshotPath = `${test.location.file}-snapshots/${image}-${test.browser}-${process.platform}.png`;
525
- try {
526
- await copyFilePortable(actualPath, snapshotPath);
527
- console.log(` \u2714 Updated baseline: ${snapshotPath}`);
528
- } catch (err) {
529
- const errorMsg = err instanceof Error ? err.message : String(err);
530
- console.error(` \u2717 Failed to update baseline: ${errorMsg}`);
531
- }
532
- }
910
+ console.log(` \u2714 Updated baseline: ${snapshotPath}`);
533
911
  console.log(` \u2714 Approved [${test.browser}] ${test.title} \u2014 ${image}`);
912
+ return Response.json({ success: true });
913
+ } catch (err) {
914
+ const errorMsg = err instanceof Error ? err.message : String(err);
915
+ console.error(` \u2717 Failed to update baseline: ${errorMsg}`);
916
+ return Response.json({ success: false, error: "Failed to update baseline" }, { status: 500 });
534
917
  }
535
- return Response.json({ success: true });
536
918
  } catch {
537
919
  return Response.json({ success: false, error: "Invalid request" }, { status: 400 });
538
920
  }
539
921
  }
540
- async function handleApiApproveAll(ctx) {
541
- let approvedCount = 0;
542
- const baselineUpdates = [];
543
- Object.values(ctx.reportData.tests).forEach((test) => {
544
- if (!test?.results) return;
545
- const approved = {};
922
+ function createBulkApprovalUpdates(ctx) {
923
+ return Object.values(ctx.reportData.tests).flatMap((test) => {
924
+ if (!test.results || test.results.length === 0) {
925
+ return [];
926
+ }
546
927
  const lastRetry = test.results.length - 1;
547
928
  const lastResult = test.results[lastRetry];
548
- if (!lastResult?.images) return;
549
- Object.keys(lastResult.images).forEach((imageName) => {
550
- approved[imageName] = lastRetry;
551
- approvedCount++;
929
+ if (!lastResult?.images) {
930
+ return [];
931
+ }
932
+ return Object.keys(lastResult.images).flatMap((imageName) => {
552
933
  const actualUrl = lastResult.images?.[imageName]?.actual;
553
- if (actualUrl !== null && actualUrl !== void 0 && test.location?.file !== null && test.location?.file !== void 0) {
554
- const actualPath = actualUrl.replace("/screenshots/", `${ctx.reportData.screenshotDir}/`);
555
- const snapshotPath = `${test.location.file}-snapshots/${imageName}-${test.browser}-${process.platform}.png`;
556
- baselineUpdates.push(
557
- copyFilePortable(actualPath, snapshotPath).then(() => {
558
- console.log(` \u2714 Updated baseline: ${snapshotPath}`);
559
- }).catch((err) => {
560
- const errorMsg = err instanceof Error ? err.message : String(err);
561
- console.error(` \u2717 Failed to update baseline: ${errorMsg}`);
562
- })
563
- );
934
+ if (actualUrl === void 0) {
935
+ return [Promise.resolve({ kind: "unresolved" })];
936
+ }
937
+ const snapshotPath = resolveApprovalTarget(ctx, test, lastRetry, imageName);
938
+ if (snapshotPath === null) {
939
+ return [Promise.resolve({ kind: "unresolved" })];
564
940
  }
941
+ return [
942
+ copyFilePortable(actualPathFromUrl(ctx, actualUrl), snapshotPath).then(
943
+ () => ({
944
+ kind: "approved",
945
+ imageName,
946
+ retry: lastRetry,
947
+ snapshotPath,
948
+ test
949
+ })
950
+ ).catch((err) => {
951
+ const errorMsg = err instanceof Error ? err.message : String(err);
952
+ console.error(` \u2717 Failed to update baseline: ${errorMsg}`);
953
+ return { kind: "failed" };
954
+ })
955
+ ];
565
956
  });
566
- test.approved = approved;
567
957
  });
958
+ }
959
+ function summarizeBulkApprovalOutcomes(outcomes) {
960
+ return outcomes.reduce(
961
+ (summary, outcome) => {
962
+ switch (outcome.kind) {
963
+ case "approved": {
964
+ outcome.test.approved = { ...outcome.test.approved ?? {}, [outcome.imageName]: outcome.retry };
965
+ console.log(` \u2714 Updated baseline: ${outcome.snapshotPath}`);
966
+ return { ...summary, approved: summary.approved + 1 };
967
+ }
968
+ case "unresolved":
969
+ return { ...summary, unresolved: summary.unresolved + 1 };
970
+ case "failed":
971
+ return { ...summary, failed: summary.failed + 1 };
972
+ }
973
+ },
974
+ { approved: 0, unresolved: 0, failed: 0 }
975
+ );
976
+ }
977
+ async function handleApiApproveAll(ctx) {
978
+ const outcomes = await Promise.all(createBulkApprovalUpdates(ctx));
979
+ const counts = summarizeBulkApprovalOutcomes(outcomes);
568
980
  await ctx.saveReport();
569
- await Promise.all(baselineUpdates);
570
- console.log(` \u2714 Approved all \u2014 ${approvedCount} image(s)`);
571
- return Response.json({ success: true });
981
+ console.log(
982
+ ` \u2714 Approved all \u2014 approved: ${counts.approved}, unresolved: ${counts.unresolved}, failed: ${counts.failed}`
983
+ );
984
+ return Response.json({ success: counts.failed === 0, ...counts });
572
985
  }
573
986
  async function handleApiImages(req) {
574
987
  const path = new URL(req.url).pathname.slice("/api/images/".length);
@@ -584,7 +997,7 @@ async function handleScreenshots(ctx, req) {
584
997
  }
585
998
  async function handleDist(ctx, req) {
586
999
  const path = new URL(req.url).pathname.slice("/dist/".length);
587
- const filePath = (0, import_path3.join)(ctx.staticDir, path);
1000
+ const filePath = (0, import_path5.join)(ctx.staticDir, path);
588
1001
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
589
1002
  const file = await respondWithFile(filePath, contentType);
590
1003
  return file ?? new Response("Not Found", { status: 404 });
@@ -603,10 +1016,10 @@ function handleHttpRequest(ctx, req) {
603
1016
  if (pathname === "/api/report") {
604
1017
  return Promise.resolve(handleApiReport(ctx));
605
1018
  }
606
- if (pathname === "/api/approve") {
1019
+ if (pathname === "/api/approve" && req.method === "POST") {
607
1020
  return handleApiApprove(ctx, req);
608
1021
  }
609
- if (pathname === "/api/approve-all") {
1022
+ if (pathname === "/api/approve-all" && req.method === "POST") {
610
1023
  return handleApiApproveAll(ctx);
611
1024
  }
612
1025
  if (pathname.startsWith("/api/images/")) {
@@ -688,6 +1101,10 @@ async function loadOfflineReports(offlineReportDir, reportData) {
688
1101
  screenshotsBaseUrl: "/screenshots/"
689
1102
  });
690
1103
  }
1104
+ function resetReloadableReportData(reportData) {
1105
+ reportData.tests = {};
1106
+ reportData.isUpdateMode = false;
1107
+ }
691
1108
  async function handleParsedWebSocketMessage(ctx, msg) {
692
1109
  switch (msg.type) {
693
1110
  case "test-begin": {
@@ -736,19 +1153,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
736
1153
  };
737
1154
  }
738
1155
  async function resolveStaticDir(staticDir) {
739
- const currentDir = (0, import_path4.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1156
+ const currentDir = (0, import_path6.dirname)((0, import_url.fileURLToPath)(import_meta.url));
740
1157
  const candidates = staticDir === void 0 ? [
741
1158
  currentDir,
742
- (0, import_path4.join)(currentDir, "dist"),
743
- (0, import_path4.join)(currentDir, "..", "dist"),
744
- (0, import_path4.join)(currentDir, "..", "..", "dist"),
745
- (0, import_path4.join)(currentDir, ".."),
746
- (0, import_path4.join)(currentDir, "..", "..")
747
- ] : [staticDir, (0, import_path4.join)(staticDir, "dist")];
1159
+ (0, import_path6.join)(currentDir, "dist"),
1160
+ (0, import_path6.join)(currentDir, "..", "dist"),
1161
+ (0, import_path6.join)(currentDir, "..", "..", "dist"),
1162
+ (0, import_path6.join)(currentDir, ".."),
1163
+ (0, import_path6.join)(currentDir, "..", "..")
1164
+ ] : [staticDir, (0, import_path6.join)(staticDir, "dist")];
748
1165
  const resolvedCandidates = await Promise.all(
749
1166
  candidates.map(async (candidate) => ({
750
1167
  candidate,
751
- exists: await fileExists((0, import_path4.join)(candidate, "index.html"))
1168
+ exists: await fileExists((0, import_path6.join)(candidate, "index.html"))
752
1169
  }))
753
1170
  );
754
1171
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -759,9 +1176,23 @@ async function resolveStaticDir(staticDir) {
759
1176
  }
760
1177
  async function resolveReportPath(reportPath) {
761
1178
  if (await isDirectory(reportPath)) {
762
- return { reportFile: (0, import_path4.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1179
+ return { reportFile: (0, import_path6.join)(reportPath, "report.json"), offlineReportDir: reportPath };
763
1180
  }
764
- return { reportFile: reportPath, offlineReportDir: (0, import_path4.dirname)(reportPath) };
1181
+ return { reportFile: reportPath, offlineReportDir: (0, import_path6.dirname)(reportPath) };
1182
+ }
1183
+ function createRoutesContext(reportData, staticDir, saveReport, options) {
1184
+ return {
1185
+ reportData,
1186
+ staticDir,
1187
+ saveReport,
1188
+ approvalRouting: {
1189
+ configDir: options.configDir ?? process.cwd(),
1190
+ playwrightTestDir: options.playwrightTestDir,
1191
+ playwrightSnapshotDir: options.playwrightSnapshotDir,
1192
+ playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1193
+ playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1194
+ }
1195
+ };
765
1196
  }
766
1197
  async function createServerApp(options = {}) {
767
1198
  const port = options.port ?? 3e3;
@@ -774,19 +1205,26 @@ async function createServerApp(options = {}) {
774
1205
  async function saveReport() {
775
1206
  await writeJsonFile(reportFile, reportData);
776
1207
  }
777
- const routesContext = {
778
- reportData,
779
- staticDir,
780
- saveReport
781
- };
1208
+ const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
782
1209
  const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
783
1210
  const handleRequest = (req) => handleHttpRequest(routesContext, req);
784
1211
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
785
- await loadReport(reportFile, reportData);
786
- await loadOfflineReports(offlineReportDir, reportData);
1212
+ const reloadFromDisk = async () => {
1213
+ resetReloadableReportData(reportData);
1214
+ await loadReport(reportFile, reportData);
1215
+ await loadOfflineReports(offlineReportDir, reportData);
1216
+ broadcastToBrowsers(wsClients, { type: "sync", data: reportData });
1217
+ };
1218
+ await reloadFromDisk();
1219
+ const close = await watchReportArtifacts({
1220
+ offlineReportDir,
1221
+ screenshotDir: reportData.screenshotDir,
1222
+ scheduleRefresh: createDebouncedRefresh(reloadFromDisk)
1223
+ });
787
1224
  return {
788
1225
  port,
789
1226
  wsClients,
1227
+ close,
790
1228
  handleRequest,
791
1229
  handleWebSocketMessage
792
1230
  };
@@ -967,7 +1405,7 @@ function attachWebSocketServer(server, app) {
967
1405
  });
968
1406
  }
969
1407
  async function listen(server, port) {
970
- await new Promise((resolve, reject) => {
1408
+ await new Promise((resolve2, reject) => {
971
1409
  const onError = (error) => {
972
1410
  server.off("error", onError);
973
1411
  reject(error);
@@ -975,7 +1413,7 @@ async function listen(server, port) {
975
1413
  server.on("error", onError);
976
1414
  server.listen(port, () => {
977
1415
  server.off("error", onError);
978
- resolve();
1416
+ resolve2();
979
1417
  });
980
1418
  });
981
1419
  }