@crvy/rprtr 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/{chunk-XU5VQ3FZ.js → chunk-35CZLYSW.js} +223 -192
  3. package/dist/{chunk-EJRLZZ22.js → chunk-43JLQN36.js} +45 -5
  4. package/dist/ci.d.ts +2 -0
  5. package/dist/ci.d.ts.map +1 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +7 -4
  9. package/dist/index.js +338 -46
  10. package/dist/report-utils.d.ts.map +1 -1
  11. package/dist/reporter-artifact-ops.d.ts +1 -3
  12. package/dist/reporter-artifact-ops.d.ts.map +1 -1
  13. package/dist/reporter.cjs +285 -198
  14. package/dist/reporter.d.ts +5 -3
  15. package/dist/reporter.d.ts.map +1 -1
  16. package/dist/reporter.js +81 -31
  17. package/dist/schemas.d.ts +210 -1
  18. package/dist/schemas.d.ts.map +1 -1
  19. package/dist/server/app.d.ts +6 -0
  20. package/dist/server/app.d.ts.map +1 -1
  21. package/dist/server/artifact-routes.d.ts +8 -0
  22. package/dist/server/artifact-routes.d.ts.map +1 -0
  23. package/dist/server/file-utils.d.ts.map +1 -1
  24. package/dist/server/handlers.d.ts +6 -2
  25. package/dist/server/handlers.d.ts.map +1 -1
  26. package/dist/server/routes.d.ts +2 -2
  27. package/dist/server/routes.d.ts.map +1 -1
  28. package/dist/server/utils.d.ts +3 -0
  29. package/dist/server/utils.d.ts.map +1 -1
  30. package/dist/server.cjs +281 -228
  31. package/dist/server.js +2 -2
  32. package/dist/snapshot-path-resolver.d.ts +2 -0
  33. package/dist/snapshot-path-resolver.d.ts.map +1 -1
  34. package/dist/types.d.ts +24 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/dist/server/report-watch.d.ts +0 -9
  38. package/dist/server/report-watch.d.ts.map +0 -1
package/dist/server.cjs CHANGED
@@ -35,15 +35,16 @@ __export(server_exports, {
35
35
  module.exports = __toCommonJS(server_exports);
36
36
 
37
37
  // src/server/app.ts
38
- var import_path6 = require("path");
38
+ var import_path8 = require("path");
39
39
  var import_url = require("url");
40
40
  var import_p_limit = __toESM(require("p-limit"), 1);
41
41
 
42
42
  // src/offline-reports.ts
43
43
  var import_promises = require("fs/promises");
44
- var import_path = require("path");
44
+ var import_path2 = require("path");
45
45
 
46
46
  // src/report-utils.ts
47
+ var import_path = require("path");
47
48
  function normalizeScreenshotsBaseUrl(baseUrl) {
48
49
  return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
49
50
  }
@@ -88,7 +89,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
88
89
  const role = match[2];
89
90
  if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
90
91
  images[baseName] ??= {};
91
- const url = `${baseUrl}${attachment.path}`;
92
+ const url = (0, import_path.isAbsolute)(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
92
93
  const img = images[baseName];
93
94
  if (img !== null && img !== void 0) {
94
95
  if (role === "actual") img.actual = url;
@@ -300,10 +301,29 @@ var CrvyRprtrSuiteSchema = import_zod.z.lazy(
300
301
  children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
301
302
  })
302
303
  );
303
- var WebSocketMessageSchema = import_zod.z.object({
304
+ var IncomingWebSocketMessageSchema = import_zod.z.object({
304
305
  type: import_zod.z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
305
306
  data: import_zod.z.unknown()
306
307
  });
308
+ var WebSocketMessageSchema = import_zod.z.discriminatedUnion("type", [
309
+ import_zod.z.object({ type: import_zod.z.literal("test-begin"), data: TestDataSchema }),
310
+ import_zod.z.object({ type: import_zod.z.literal("test-update"), data: TestDataSchema }),
311
+ import_zod.z.object({
312
+ type: import_zod.z.literal("run-end"),
313
+ data: import_zod.z.object({
314
+ status: import_zod.z.enum(["passed", "failed", "skipped"]),
315
+ removedTestIds: import_zod.z.array(import_zod.z.string())
316
+ })
317
+ }),
318
+ import_zod.z.object({
319
+ type: import_zod.z.literal("sync"),
320
+ data: import_zod.z.object({
321
+ tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
322
+ isUpdateMode: import_zod.z.boolean().optional()
323
+ })
324
+ }),
325
+ import_zod.z.object({ type: import_zod.z.literal("approve"), data: import_zod.z.unknown() })
326
+ ]);
307
327
  var TestBeginDataSchema = import_zod.z.object({
308
328
  id: import_zod.z.string(),
309
329
  title: import_zod.z.string(),
@@ -323,6 +343,9 @@ var TestEndDataSchema = import_zod.z.object({
323
343
  error: import_zod.z.string().optional(),
324
344
  duration: import_zod.z.number().optional()
325
345
  });
346
+ var RunEndDataSchema = import_zod.z.object({
347
+ status: import_zod.z.enum(["passed", "failed", "skipped"])
348
+ });
326
349
  var ReportDataSchema = import_zod.z.object({
327
350
  isRunning: import_zod.z.boolean(),
328
351
  tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
@@ -377,7 +400,7 @@ var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
377
400
  async function findOfflineReportPaths(searchDir) {
378
401
  try {
379
402
  const entries = await (0, import_promises.readdir)(searchDir);
380
- return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path.join)(searchDir, entry));
403
+ return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path2.join)(searchDir, entry));
381
404
  } catch (error) {
382
405
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
383
406
  return [];
@@ -429,7 +452,7 @@ function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {
429
452
 
430
453
  // src/server/file-utils.ts
431
454
  var import_promises2 = require("fs/promises");
432
- var import_path2 = require("path");
455
+ var import_path3 = require("path");
433
456
  function isFileNotFound(error) {
434
457
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
435
458
  }
@@ -470,16 +493,16 @@ async function readJsonFile(filePath) {
470
493
  }
471
494
  }
472
495
  async function writeJsonFile(filePath, value) {
473
- await (0, import_promises2.mkdir)((0, import_path2.dirname)(filePath), { recursive: true });
496
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(filePath), { recursive: true });
474
497
  await (0, import_promises2.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
475
498
  `);
476
499
  }
477
500
  async function copyFilePortable(sourcePath, destinationPath) {
478
- await (0, import_promises2.mkdir)((0, import_path2.dirname)(destinationPath), { recursive: true });
501
+ await (0, import_promises2.mkdir)((0, import_path3.dirname)(destinationPath), { recursive: true });
479
502
  await (0, import_promises2.copyFile)(sourcePath, destinationPath);
480
503
  }
481
504
  function inferContentType(filePath) {
482
- switch ((0, import_path2.extname)(filePath).toLowerCase()) {
505
+ switch ((0, import_path3.extname)(filePath).toLowerCase()) {
483
506
  case ".css":
484
507
  return "text/css";
485
508
  case ".gif":
@@ -509,7 +532,10 @@ async function respondWithFile(filePath, contentType) {
509
532
  try {
510
533
  const file = await (0, import_promises2.readFile)(filePath);
511
534
  const resolvedContentType = contentType ?? inferContentType(filePath);
512
- const headers = resolvedContentType === void 0 ? void 0 : { "Content-Type": resolvedContentType };
535
+ const headers = { "X-Content-Type-Options": "nosniff" };
536
+ if (resolvedContentType !== void 0) {
537
+ headers["Content-Type"] = resolvedContentType;
538
+ }
513
539
  return new Response(file, { headers });
514
540
  } catch (error) {
515
541
  if (isFileNotFound(error)) {
@@ -519,160 +545,13 @@ async function respondWithFile(filePath, contentType) {
519
545
  }
520
546
  }
521
547
 
522
- // src/server/utils.ts
523
- function broadcastToBrowsers(wsClients, msg) {
524
- const payload = JSON.stringify(msg);
525
- wsClients.forEach((ws) => {
526
- ws.send(payload);
527
- });
528
- }
529
-
530
548
  // src/server/handlers.ts
531
- function handleTestBegin(ctx, data) {
532
- const test = applyTestBeginEvent(ctx, data);
533
- ctx.reportData.isRunning = true;
534
- console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
535
- broadcastToBrowsers(ctx.wsClients, { type: "test-begin", data });
536
- }
537
- function handleTestEnd(ctx, data) {
538
- const result = applyTestEndEvent(ctx, data);
539
- if (result !== null) {
540
- const { test, diffCount } = result;
541
- const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
542
- const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
543
- const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
544
- const errNote = data.error !== null && data.error !== void 0 ? `
545
- Error: ${data.error}` : "";
546
- console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
547
- }
548
- broadcastToBrowsers(ctx.wsClients, { type: "test-update", data });
549
- }
550
- async function handleRunEnd(ctx, data) {
551
- const { passed, failed, pending } = finalizeRunEvent(ctx);
552
- await ctx.saveReport();
553
- console.log(`
554
- Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
555
- broadcastToBrowsers(ctx.wsClients, { type: "run-end", data });
556
- }
557
- function handleApprove() {
558
- console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
559
- }
560
- function handleSync(ctx) {
561
- console.log("[Server] Received sync message");
562
- broadcastToBrowsers(ctx.wsClients, { type: "sync", data: ctx.reportData });
563
- }
564
-
565
- // src/server/report-watch.ts
566
- var import_promises3 = require("fs/promises");
567
- var import_path3 = require("path");
568
- var OFFLINE_REPORT_FILE_PATTERN2 = /^crvy-rprtr(?:-\d+)?\.json$/;
569
- function createDebouncedRefresh(reload, delayMs = 50) {
570
- let timer = null;
571
- return () => {
572
- if (timer !== null) {
573
- clearTimeout(timer);
574
- }
575
- timer = setTimeout(() => {
576
- timer = null;
577
- void reload();
578
- }, delayMs);
579
- };
580
- }
581
- function isFileNotFound2(error) {
582
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
583
- }
584
- async function describeFile(filePath, label) {
585
- try {
586
- const fileStats = await (0, import_promises3.stat)(filePath);
587
- return `${label}:${fileStats.size}:${fileStats.mtimeMs}`;
588
- } catch (error) {
589
- if (isFileNotFound2(error)) {
590
- return null;
591
- }
592
- throw error;
593
- }
594
- }
595
- async function createOfflineFingerprint(offlineReportDir) {
596
- if (!await isDirectory(offlineReportDir)) {
597
- return "offline:missing";
598
- }
599
- try {
600
- const entries = await (0, import_promises3.readdir)(offlineReportDir, { withFileTypes: true });
601
- const relevantEntries = entries.filter(
602
- (entry) => entry.isFile() && (entry.name === "report.json" || OFFLINE_REPORT_FILE_PATTERN2.test(entry.name))
603
- ).sort((left, right) => left.name.localeCompare(right.name));
604
- const parts = await Promise.all(
605
- relevantEntries.map((entry) => describeFile((0, import_path3.join)(offlineReportDir, entry.name), entry.name))
606
- );
607
- return `offline:${parts.filter((part) => part !== null).join("|")}`;
608
- } catch (error) {
609
- const errorMsg = error instanceof Error ? error.message : String(error);
610
- console.warn(`[Server] Unable to scan ${offlineReportDir}: ${errorMsg}`);
611
- return "offline:error";
612
- }
613
- }
614
- async function createScreenshotFingerprint(screenshotDir, relativePath = "") {
615
- const directoryPath = relativePath === "" ? screenshotDir : (0, import_path3.join)(screenshotDir, relativePath);
616
- if (!await isDirectory(directoryPath)) {
617
- return relativePath === "" ? ["screenshots:missing"] : [];
618
- }
619
- try {
620
- const entries = await (0, import_promises3.readdir)(directoryPath, { withFileTypes: true });
621
- const parts = await Promise.all(
622
- entries.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
623
- const entryRelativePath = relativePath === "" ? entry.name : (0, import_path3.join)(relativePath, entry.name);
624
- if (entry.isDirectory()) {
625
- return createScreenshotFingerprint(screenshotDir, entryRelativePath);
626
- }
627
- return describeFile((0, import_path3.join)(screenshotDir, entryRelativePath), entryRelativePath);
628
- })
629
- );
630
- return parts.flat().filter((part) => part !== null);
631
- } catch (error) {
632
- if (isFileNotFound2(error)) {
633
- return relativePath === "" ? ["screenshots:missing"] : [];
634
- }
635
- const errorMsg = error instanceof Error ? error.message : String(error);
636
- console.warn(`[Server] Unable to scan ${directoryPath}: ${errorMsg}`);
637
- return relativePath === "" ? ["screenshots:error"] : [];
638
- }
639
- }
640
- async function createArtifactsFingerprint(options) {
641
- const [offlineFingerprint, screenshotFingerprint] = await Promise.all([
642
- createOfflineFingerprint(options.offlineReportDir),
643
- createScreenshotFingerprint(options.screenshotDir)
644
- ]);
645
- return `${offlineFingerprint}::${screenshotFingerprint.join("|")}`;
646
- }
647
- async function watchReportArtifacts(options) {
648
- let fingerprint = await createArtifactsFingerprint(options);
649
- let isPolling = false;
650
- const interval = setInterval(() => {
651
- if (isPolling) {
652
- return;
653
- }
654
- isPolling = true;
655
- void createArtifactsFingerprint(options).then((nextFingerprint) => {
656
- if (nextFingerprint === fingerprint) {
657
- return;
658
- }
659
- fingerprint = nextFingerprint;
660
- options.scheduleRefresh();
661
- }).catch((error) => {
662
- const errorMsg = error instanceof Error ? error.message : String(error);
663
- console.warn(`[Server] Artifact refresh poll failed: ${errorMsg}`);
664
- }).finally(() => {
665
- isPolling = false;
666
- });
667
- }, 100);
668
- return () => {
669
- clearInterval(interval);
670
- };
671
- }
549
+ var import_fs2 = require("fs");
672
550
 
673
- // src/server/routes.ts
551
+ // src/server/artifact-routes.ts
674
552
  var import_fs = require("fs");
675
- var import_path5 = require("path");
553
+ var import_promises3 = require("fs/promises");
554
+ var import_path6 = require("path");
676
555
 
677
556
  // src/snapshot-path-resolver.ts
678
557
  var import_crypto = require("crypto");
@@ -807,9 +686,11 @@ function resolveNamedTarget(input, declaration) {
807
686
  function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
808
687
  return reporterTitlePath2.slice(3).filter((part) => part !== "");
809
688
  }
689
+ function anonymousNameFromTitles(titles, occurrenceIndex) {
690
+ return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
691
+ }
810
692
  function anonymousName(reporterTitlePath2, occurrenceIndex) {
811
- const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath2).join(" ")} ${occurrenceIndex}.png`;
812
- return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
693
+ return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath2), occurrenceIndex);
813
694
  }
814
695
  function resolveTarget(input, declaration) {
815
696
  switch (declaration.kind) {
@@ -830,41 +711,67 @@ function resolveBaselineTargets(input) {
830
711
  });
831
712
  }
832
713
 
833
- // src/server/routes.ts
714
+ // src/server/utils.ts
715
+ var import_path5 = require("path");
834
716
  var LIVE_UPDATES_WEBSOCKET_PATH = "/";
717
+ function broadcastToBrowsers(wsClients, msg) {
718
+ const payload = JSON.stringify(msg);
719
+ wsClients.forEach((ws) => {
720
+ ws.send(payload);
721
+ });
722
+ }
835
723
  function isWebSocketUpgradeRequest(req) {
836
724
  return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
837
725
  }
838
- async function handleRoot(ctx) {
839
- const html = await respondWithFile((0, import_path5.join)(ctx.staticDir, "index.html"), "text/html");
840
- return html ?? new Response("Not Found", { status: 404 });
841
- }
842
- async function handleAppCss() {
843
- const css = await respondWithFile("./src/client/app.css", "text/css");
844
- return css ?? new Response("Not Found", { status: 404 });
845
- }
846
- async function handleSrcFiles(req) {
847
- const path = new URL(req.url).pathname.slice("/src/".length);
848
- const filePath = `./src/${path}`;
849
- const contentType = filePath.endsWith(".ts") || filePath.endsWith(".tsx") ? "application/javascript" : filePath.endsWith(".css") ? "text/css" : "text/plain";
850
- const file = await respondWithFile(filePath, contentType);
851
- return file ?? new Response("Not Found", { status: 404 });
726
+ function isPathWithinRoots(target, roots) {
727
+ const resolvedTarget = (0, import_path5.resolve)(target);
728
+ return roots.some((root) => {
729
+ const rel = (0, import_path5.relative)((0, import_path5.resolve)(root), resolvedTarget);
730
+ return rel === "" || !rel.startsWith(`..${import_path5.sep}`) && rel !== ".." && !(0, import_path5.isAbsolute)(rel);
731
+ });
852
732
  }
853
- function handleApiReport(ctx) {
854
- return Response.json(ctx.reportData);
733
+
734
+ // src/server/artifact-routes.ts
735
+ async function realpathOrNull(path) {
736
+ try {
737
+ return await (0, import_promises3.realpath)(path);
738
+ } catch {
739
+ return null;
740
+ }
855
741
  }
856
- var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
857
- function actualPathFromUrl(ctx, actualUrl) {
858
- return actualUrl.startsWith("/screenshots/") ? (0, import_path5.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length)) : actualUrl;
742
+ async function handleFile(ctx, req) {
743
+ const notFound = () => new Response("Not Found", { status: 404 });
744
+ let decodedPath;
745
+ try {
746
+ decodedPath = (0, import_path6.resolve)(decodeURIComponent(new URL(req.url).pathname.slice("/file/".length)));
747
+ } catch {
748
+ return notFound();
749
+ }
750
+ const realTarget = await realpathOrNull(decodedPath);
751
+ if (realTarget === null) {
752
+ return notFound();
753
+ }
754
+ const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull((0, import_path6.resolve)(root))))).filter(
755
+ (root) => root !== null
756
+ );
757
+ if (!isPathWithinRoots(realTarget, realRoots)) {
758
+ return notFound();
759
+ }
760
+ try {
761
+ const file = await respondWithFile(realTarget);
762
+ return file ?? notFound();
763
+ } catch {
764
+ return notFound();
765
+ }
859
766
  }
860
767
  function reporterTitlePath(test) {
861
768
  const testFile = test.location?.file;
862
769
  return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
863
770
  }
864
- function resolveApprovalTarget(ctx, test, retry, imageName) {
771
+ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
865
772
  const testFile = test.location?.file;
866
773
  const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
867
- if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
774
+ if (routing === void 0 || testFile === void 0 || declaration === void 0) {
868
775
  return null;
869
776
  }
870
777
  const targets = resolveBaselineTargets({
@@ -872,18 +779,162 @@ function resolveApprovalTarget(ctx, test, retry, imageName) {
872
779
  reporterTitlePath: reporterTitlePath(test),
873
780
  declarations: [declaration],
874
781
  config: {
875
- configDir: ctx.approvalRouting.configDir,
876
- testDir: ctx.approvalRouting.playwrightTestDir ?? (0, import_path5.dirname)(testFile),
877
- snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? (0, import_path5.dirname)(testFile),
782
+ configDir: routing.configDir,
783
+ testDir: routing.playwrightTestDir ?? (0, import_path6.dirname)(testFile),
784
+ snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path6.dirname)(testFile),
878
785
  projectName: test.browser,
879
786
  snapshotSuffix: process.platform,
880
- snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
881
- toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
787
+ snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
788
+ toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
882
789
  },
883
790
  snapshotPathExists: import_fs.existsSync
884
791
  });
885
792
  return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
886
793
  }
794
+ async function handleBaseline(ctx, req) {
795
+ const notFound = () => new Response("Not Found", { status: 404 });
796
+ let testId;
797
+ let retry;
798
+ let visualName;
799
+ try {
800
+ const segments = new URL(req.url).pathname.slice("/baseline/".length).split("/");
801
+ const [rawTestId, retryRaw, ...visualNameParts] = segments;
802
+ if (rawTestId === void 0 || retryRaw === void 0 || !/^\d+$/.test(retryRaw) || visualNameParts.length === 0) {
803
+ return notFound();
804
+ }
805
+ testId = decodeURIComponent(rawTestId);
806
+ retry = Number(retryRaw);
807
+ visualName = decodeURIComponent(visualNameParts.join("/"));
808
+ } catch {
809
+ return notFound();
810
+ }
811
+ const test = ctx.reportData.tests[testId];
812
+ if (test === void 0) {
813
+ return notFound();
814
+ }
815
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
816
+ if (snapshotPath === null) {
817
+ return notFound();
818
+ }
819
+ try {
820
+ const file = await respondWithFile(snapshotPath);
821
+ return file ?? notFound();
822
+ } catch {
823
+ return notFound();
824
+ }
825
+ }
826
+ function handleArtifactRoute(ctx, req, pathname) {
827
+ if (pathname.startsWith("/file/")) {
828
+ return handleFile(ctx, req);
829
+ }
830
+ if (pathname.startsWith("/baseline/")) {
831
+ return handleBaseline(ctx, req);
832
+ }
833
+ return null;
834
+ }
835
+
836
+ // src/server/handlers.ts
837
+ function handleTestBegin(ctx, data) {
838
+ const test = applyTestBeginEvent(ctx, data);
839
+ ctx.reportData.isRunning = true;
840
+ console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
841
+ const message = { type: "test-begin", data: test };
842
+ broadcastToBrowsers(ctx.wsClients, message);
843
+ }
844
+ function enrichDeclaredBaselines(ctx, test) {
845
+ const retry = (test.results?.length ?? 0) - 1;
846
+ const images = test.results?.[retry]?.images;
847
+ if (retry < 0 || images === void 0) {
848
+ return;
849
+ }
850
+ for (const [visualName, image] of Object.entries(images)) {
851
+ if (image === void 0 || image.source !== "declared-only") {
852
+ continue;
853
+ }
854
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
855
+ if (snapshotPath === null || !(0, import_fs2.existsSync)(snapshotPath)) {
856
+ continue;
857
+ }
858
+ image.expect = `/baseline/${encodeURIComponent(test.id)}/${retry}/${encodeURIComponent(visualName)}`;
859
+ image.source = "baseline-only";
860
+ }
861
+ }
862
+ function handleTestEnd(ctx, data) {
863
+ const result = applyTestEndEvent(ctx, data);
864
+ if (result === null) {
865
+ console.error("[Server] test-end for unknown test id:", data.id);
866
+ return;
867
+ }
868
+ const { test, diffCount } = result;
869
+ if (data.status === "passed") {
870
+ enrichDeclaredBaselines(ctx, test);
871
+ }
872
+ const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
873
+ const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
874
+ const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
875
+ const errNote = data.error !== null && data.error !== void 0 ? `
876
+ Error: ${data.error}` : "";
877
+ console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
878
+ const message = { type: "test-update", data: test };
879
+ broadcastToBrowsers(ctx.wsClients, message);
880
+ }
881
+ async function handleRunEnd(ctx, data) {
882
+ const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
883
+ const { passed, failed, pending } = finalizeRunEvent(ctx);
884
+ await ctx.saveReport();
885
+ console.log(`
886
+ Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
887
+ const message = {
888
+ type: "run-end",
889
+ data: { status: data.status, removedTestIds }
890
+ };
891
+ broadcastToBrowsers(ctx.wsClients, message);
892
+ }
893
+ function handleApprove() {
894
+ console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
895
+ }
896
+ function handleSync(ctx) {
897
+ console.log("[Server] Received sync message");
898
+ const message = {
899
+ type: "sync",
900
+ data: {
901
+ tests: ctx.reportData.tests,
902
+ isUpdateMode: ctx.reportData.isUpdateMode
903
+ }
904
+ };
905
+ broadcastToBrowsers(ctx.wsClients, message);
906
+ }
907
+
908
+ // src/server/routes.ts
909
+ var import_path7 = require("path");
910
+ async function handleRoot(ctx) {
911
+ const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
912
+ return html ?? new Response("Not Found", { status: 404 });
913
+ }
914
+ async function handleAppCss() {
915
+ const css = await respondWithFile("./src/client/app.css", "text/css");
916
+ return css ?? new Response("Not Found", { status: 404 });
917
+ }
918
+ async function handleSrcFiles(req) {
919
+ const path = new URL(req.url).pathname.slice("/src/".length);
920
+ const filePath = `./src/${path}`;
921
+ const contentType = filePath.endsWith(".ts") || filePath.endsWith(".tsx") ? "application/javascript" : filePath.endsWith(".css") ? "text/css" : "text/plain";
922
+ const file = await respondWithFile(filePath, contentType);
923
+ return file ?? new Response("Not Found", { status: 404 });
924
+ }
925
+ function handleApiReport(ctx) {
926
+ return Response.json(ctx.reportData);
927
+ }
928
+ var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
929
+ function actualPathFromUrl(ctx, actualUrl) {
930
+ if (actualUrl.startsWith("/screenshots/")) {
931
+ return (0, import_path7.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
932
+ }
933
+ if (actualUrl.startsWith("/file/")) {
934
+ return decodeURIComponent(actualUrl.slice("/file/".length));
935
+ }
936
+ return actualUrl;
937
+ }
887
938
  async function handleApiApprove(ctx, req) {
888
939
  try {
889
940
  const rawBody = await req.json();
@@ -901,7 +952,7 @@ async function handleApiApprove(ctx, req) {
901
952
  if (actualUrl === void 0) {
902
953
  return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
903
954
  }
904
- const snapshotPath = resolveApprovalTarget(ctx, test, retry, image);
955
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
905
956
  if (snapshotPath === null) {
906
957
  return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
907
958
  }
@@ -936,7 +987,7 @@ function createBulkApprovalUpdates(ctx) {
936
987
  if (actualUrl === void 0) {
937
988
  return [Promise.resolve({ kind: "unresolved" })];
938
989
  }
939
- const snapshotPath = resolveApprovalTarget(ctx, test, lastRetry, imageName);
990
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
940
991
  if (snapshotPath === null) {
941
992
  return [Promise.resolve({ kind: "unresolved" })];
942
993
  }
@@ -999,7 +1050,7 @@ async function handleScreenshots(ctx, req) {
999
1050
  }
1000
1051
  async function handleDist(ctx, req) {
1001
1052
  const path = new URL(req.url).pathname.slice("/dist/".length);
1002
- const filePath = (0, import_path5.join)(ctx.staticDir, path);
1053
+ const filePath = (0, import_path7.join)(ctx.staticDir, path);
1003
1054
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
1004
1055
  const file = await respondWithFile(filePath, contentType);
1005
1056
  return file ?? new Response("Not Found", { status: 404 });
@@ -1027,6 +1078,10 @@ function handleHttpRequest(ctx, req) {
1027
1078
  if (pathname.startsWith("/api/images/")) {
1028
1079
  return handleApiImages(req);
1029
1080
  }
1081
+ const artifactResponse = handleArtifactRoute(ctx, req, pathname);
1082
+ if (artifactResponse !== null) {
1083
+ return artifactResponse;
1084
+ }
1030
1085
  if (pathname.startsWith("/screenshots/")) {
1031
1086
  return handleScreenshots(ctx, req);
1032
1087
  }
@@ -1048,12 +1103,13 @@ function createReportData(options) {
1048
1103
  screenshotDir: options.screenshotDir ?? "./screenshots"
1049
1104
  };
1050
1105
  }
1051
- function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
1106
+ function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
1052
1107
  return {
1053
1108
  reportData,
1054
1109
  wsClients,
1055
1110
  currentRunIds,
1056
- saveReport
1111
+ saveReport,
1112
+ approvalRouting
1057
1113
  };
1058
1114
  }
1059
1115
  async function loadReport(reportPath, reportData) {
@@ -1103,10 +1159,6 @@ async function loadOfflineReports(offlineReportDir, reportData) {
1103
1159
  screenshotsBaseUrl: "/screenshots/"
1104
1160
  });
1105
1161
  }
1106
- function resetReloadableReportData(reportData) {
1107
- reportData.tests = {};
1108
- reportData.isUpdateMode = false;
1109
- }
1110
1162
  async function handleParsedWebSocketMessage(ctx, msg) {
1111
1163
  switch (msg.type) {
1112
1164
  case "test-begin": {
@@ -1127,9 +1179,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
1127
1179
  handleTestEnd(ctx, parsed);
1128
1180
  break;
1129
1181
  }
1130
- case "run-end":
1131
- await handleRunEnd(ctx, msg.data);
1182
+ case "run-end": {
1183
+ const parsed = safeParse(RunEndDataSchema, msg.data);
1184
+ if (parsed === null) {
1185
+ console.error("Invalid run-end message data", msg.data);
1186
+ break;
1187
+ }
1188
+ await handleRunEnd(ctx, parsed);
1132
1189
  break;
1190
+ }
1133
1191
  case "approve":
1134
1192
  handleApprove();
1135
1193
  break;
@@ -1142,7 +1200,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
1142
1200
  return async function handleWebSocketMessage(message) {
1143
1201
  try {
1144
1202
  const parsed = JSON.parse(message);
1145
- const wsMessage = safeParse(WebSocketMessageSchema, parsed);
1203
+ const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
1146
1204
  if (wsMessage === null) {
1147
1205
  console.error("Invalid WebSocket message: missing or invalid type", parsed);
1148
1206
  return;
@@ -1155,19 +1213,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1155
1213
  };
1156
1214
  }
1157
1215
  async function resolveStaticDir(staticDir) {
1158
- const currentDir = (0, import_path6.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1216
+ const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1159
1217
  const candidates = staticDir === void 0 ? [
1160
1218
  currentDir,
1161
- (0, import_path6.join)(currentDir, "dist"),
1162
- (0, import_path6.join)(currentDir, "..", "dist"),
1163
- (0, import_path6.join)(currentDir, "..", "..", "dist"),
1164
- (0, import_path6.join)(currentDir, ".."),
1165
- (0, import_path6.join)(currentDir, "..", "..")
1166
- ] : [staticDir, (0, import_path6.join)(staticDir, "dist")];
1219
+ (0, import_path8.join)(currentDir, "dist"),
1220
+ (0, import_path8.join)(currentDir, "..", "dist"),
1221
+ (0, import_path8.join)(currentDir, "..", "..", "dist"),
1222
+ (0, import_path8.join)(currentDir, ".."),
1223
+ (0, import_path8.join)(currentDir, "..", "..")
1224
+ ] : [staticDir, (0, import_path8.join)(staticDir, "dist")];
1167
1225
  const resolvedCandidates = await Promise.all(
1168
1226
  candidates.map(async (candidate) => ({
1169
1227
  candidate,
1170
- exists: await fileExists((0, import_path6.join)(candidate, "index.html"))
1228
+ exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
1171
1229
  }))
1172
1230
  );
1173
1231
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1178,15 +1236,19 @@ async function resolveStaticDir(staticDir) {
1178
1236
  }
1179
1237
  async function resolveReportPath(reportPath) {
1180
1238
  if (await isDirectory(reportPath)) {
1181
- return { reportFile: (0, import_path6.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1239
+ return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1182
1240
  }
1183
- return { reportFile: reportPath, offlineReportDir: (0, import_path6.dirname)(reportPath) };
1241
+ return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
1184
1242
  }
1185
1243
  function createRoutesContext(reportData, staticDir, saveReport, options) {
1244
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1245
+ (root) => root !== void 0 && root !== ""
1246
+ );
1186
1247
  return {
1187
1248
  reportData,
1188
1249
  staticDir,
1189
1250
  saveReport,
1251
+ artifactRoots,
1190
1252
  approvalRouting: {
1191
1253
  configDir: options.configDir ?? process.cwd(),
1192
1254
  playwrightTestDir: options.playwrightTestDir,
@@ -1208,25 +1270,16 @@ async function createServerApp(options = {}) {
1208
1270
  await writeJsonFile(reportFile, reportData);
1209
1271
  }
1210
1272
  const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
1211
- const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
1273
+ const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
1212
1274
  const handleRequest = (req) => handleHttpRequest(routesContext, req);
1213
1275
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1214
- const reloadFromDisk = async () => {
1215
- resetReloadableReportData(reportData);
1216
- await loadReport(reportFile, reportData);
1217
- await loadOfflineReports(offlineReportDir, reportData);
1218
- broadcastToBrowsers(wsClients, { type: "sync", data: reportData });
1219
- };
1220
- await reloadFromDisk();
1221
- const close = await watchReportArtifacts({
1222
- offlineReportDir,
1223
- screenshotDir: reportData.screenshotDir,
1224
- scheduleRefresh: createDebouncedRefresh(reloadFromDisk)
1225
- });
1276
+ await loadReport(reportFile, reportData);
1277
+ await loadOfflineReports(offlineReportDir, reportData);
1226
1278
  return {
1227
1279
  port,
1228
1280
  wsClients,
1229
- close,
1281
+ close: () => {
1282
+ },
1230
1283
  handleRequest,
1231
1284
  handleWebSocketMessage
1232
1285
  };
@@ -1407,7 +1460,7 @@ function attachWebSocketServer(server, app) {
1407
1460
  });
1408
1461
  }
1409
1462
  async function listen(server, port) {
1410
- await new Promise((resolve2, reject) => {
1463
+ await new Promise((resolve4, reject) => {
1411
1464
  const onError = (error) => {
1412
1465
  server.off("error", onError);
1413
1466
  reject(error);
@@ -1415,7 +1468,7 @@ async function listen(server, port) {
1415
1468
  server.on("error", onError);
1416
1469
  server.listen(port, () => {
1417
1470
  server.off("error", onError);
1418
- resolve2();
1471
+ resolve4();
1419
1472
  });
1420
1473
  });
1421
1474
  }