@crvy/rprtr 0.0.9 → 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.
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_path5 = 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,56 +545,17 @@ 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
- }
549
+ var import_fs2 = require("fs");
564
550
 
565
- // src/server/routes.ts
551
+ // src/server/artifact-routes.ts
566
552
  var import_fs = require("fs");
567
- var import_path4 = require("path");
553
+ var import_promises3 = require("fs/promises");
554
+ var import_path6 = require("path");
568
555
 
569
556
  // src/snapshot-path-resolver.ts
570
557
  var import_crypto = require("crypto");
571
- var import_path3 = require("path");
558
+ var import_path4 = require("path");
572
559
  var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
573
560
  var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
574
561
  function isUnsafeFilePathCharacter(character) {
@@ -603,16 +590,16 @@ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
603
590
  const end = length - middle.length - start;
604
591
  return value.slice(0, start) + middle + value.slice(-end);
605
592
  }
606
- function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
593
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
607
594
  const base = filePath.slice(0, filePath.length - extension.length);
608
595
  return sanitizeForFilePath(base) + extension;
609
596
  }
610
597
  function addSuffixToFilePath(filePath, suffix) {
611
- const extension = (0, import_path3.extname)(filePath);
598
+ const extension = (0, import_path4.extname)(filePath);
612
599
  return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
613
600
  }
614
601
  function normalizedSnapshotDir(config) {
615
- return (0, import_path3.resolve)(config.configDir, config.snapshotDir);
602
+ return (0, import_path4.resolve)(config.configDir, config.snapshotDir);
616
603
  }
617
604
  function templateValue(template, token, value) {
618
605
  return template.replace(
@@ -622,8 +609,8 @@ function templateValue(template, token, value) {
622
609
  }
623
610
  function applyTemplate(input, nameArgument, extension) {
624
611
  const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
625
- const relativeTestFilePath = (0, import_path3.relative)(input.config.testDir, input.testFile);
626
- const parsed = (0, import_path3.parse)(relativeTestFilePath);
612
+ const relativeTestFilePath = (0, import_path4.relative)(input.config.testDir, input.testFile);
613
+ const parsed = (0, import_path4.parse)(relativeTestFilePath);
627
614
  const tokens = [
628
615
  ["testDir", input.config.testDir],
629
616
  ["snapshotDir", normalizedSnapshotDir(input.config)],
@@ -641,16 +628,16 @@ function applyTemplate(input, nameArgument, extension) {
641
628
  (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
642
629
  template
643
630
  );
644
- return (0, import_path3.resolve)(input.config.configDir, snapshotPath);
631
+ return (0, import_path4.resolve)(input.config.configDir, snapshotPath);
645
632
  }
646
- function removeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
633
+ function removeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
647
634
  return filePath.slice(0, filePath.length - extension.length);
648
635
  }
649
636
  function snapshotNameParts(declaredName) {
650
- const extension = (0, import_path3.extname)(declaredName) || ".png";
637
+ const extension = (0, import_path4.extname)(declaredName) || ".png";
651
638
  return {
652
639
  extension,
653
- filePath: (0, import_path3.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
640
+ filePath: (0, import_path4.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
654
641
  };
655
642
  }
656
643
  function filePathForOccurrence(filePath, occurrenceIndex) {
@@ -674,7 +661,7 @@ function resolveStringCallTarget(input, declaration) {
674
661
  function resolveArrayCallTarget(input, declaration) {
675
662
  const { extension, filePath } = snapshotNameParts(declaration.declaredName);
676
663
  const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
677
- const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(occurrenceFilePath), (0, import_path3.basename)(occurrenceFilePath, extension));
664
+ const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(occurrenceFilePath), (0, import_path4.basename)(occurrenceFilePath, extension));
678
665
  return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
679
666
  }
680
667
  function resolveNamedTarget(input, declaration) {
@@ -699,9 +686,11 @@ function resolveNamedTarget(input, declaration) {
699
686
  function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
700
687
  return reporterTitlePath2.slice(3).filter((part) => part !== "");
701
688
  }
689
+ function anonymousNameFromTitles(titles, occurrenceIndex) {
690
+ return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
691
+ }
702
692
  function anonymousName(reporterTitlePath2, occurrenceIndex) {
703
- const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath2).join(" ")} ${occurrenceIndex}.png`;
704
- return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
693
+ return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath2), occurrenceIndex);
705
694
  }
706
695
  function resolveTarget(input, declaration) {
707
696
  switch (declaration.kind) {
@@ -710,7 +699,7 @@ function resolveTarget(input, declaration) {
710
699
  case "unnamed": {
711
700
  const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
712
701
  const extension = ".png";
713
- const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(anonymousFileName), (0, import_path3.basename)(anonymousFileName, extension));
702
+ const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(anonymousFileName), (0, import_path4.basename)(anonymousFileName, extension));
714
703
  return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
715
704
  }
716
705
  }
@@ -722,41 +711,67 @@ function resolveBaselineTargets(input) {
722
711
  });
723
712
  }
724
713
 
725
- // src/server/routes.ts
714
+ // src/server/utils.ts
715
+ var import_path5 = require("path");
726
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
+ }
727
723
  function isWebSocketUpgradeRequest(req) {
728
724
  return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
729
725
  }
730
- async function handleRoot(ctx) {
731
- const html = await respondWithFile((0, import_path4.join)(ctx.staticDir, "index.html"), "text/html");
732
- return html ?? new Response("Not Found", { status: 404 });
733
- }
734
- async function handleAppCss() {
735
- const css = await respondWithFile("./src/client/app.css", "text/css");
736
- return css ?? new Response("Not Found", { status: 404 });
737
- }
738
- async function handleSrcFiles(req) {
739
- const path = new URL(req.url).pathname.slice("/src/".length);
740
- const filePath = `./src/${path}`;
741
- const contentType = filePath.endsWith(".ts") || filePath.endsWith(".tsx") ? "application/javascript" : filePath.endsWith(".css") ? "text/css" : "text/plain";
742
- const file = await respondWithFile(filePath, contentType);
743
- 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
+ });
744
732
  }
745
- function handleApiReport(ctx) {
746
- 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
+ }
747
741
  }
748
- var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
749
- function actualPathFromUrl(ctx, actualUrl) {
750
- return actualUrl.startsWith("/screenshots/") ? (0, import_path4.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
+ }
751
766
  }
752
767
  function reporterTitlePath(test) {
753
768
  const testFile = test.location?.file;
754
769
  return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
755
770
  }
756
- function resolveApprovalTarget(ctx, test, retry, imageName) {
771
+ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
757
772
  const testFile = test.location?.file;
758
773
  const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
759
- if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
774
+ if (routing === void 0 || testFile === void 0 || declaration === void 0) {
760
775
  return null;
761
776
  }
762
777
  const targets = resolveBaselineTargets({
@@ -764,18 +779,162 @@ function resolveApprovalTarget(ctx, test, retry, imageName) {
764
779
  reporterTitlePath: reporterTitlePath(test),
765
780
  declarations: [declaration],
766
781
  config: {
767
- configDir: ctx.approvalRouting.configDir,
768
- testDir: ctx.approvalRouting.playwrightTestDir ?? (0, import_path4.dirname)(testFile),
769
- snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? (0, import_path4.dirname)(testFile),
782
+ configDir: routing.configDir,
783
+ testDir: routing.playwrightTestDir ?? (0, import_path6.dirname)(testFile),
784
+ snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path6.dirname)(testFile),
770
785
  projectName: test.browser,
771
786
  snapshotSuffix: process.platform,
772
- snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
773
- toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
787
+ snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
788
+ toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
774
789
  },
775
790
  snapshotPathExists: import_fs.existsSync
776
791
  });
777
792
  return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
778
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
+ }
779
938
  async function handleApiApprove(ctx, req) {
780
939
  try {
781
940
  const rawBody = await req.json();
@@ -793,7 +952,7 @@ async function handleApiApprove(ctx, req) {
793
952
  if (actualUrl === void 0) {
794
953
  return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
795
954
  }
796
- const snapshotPath = resolveApprovalTarget(ctx, test, retry, image);
955
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
797
956
  if (snapshotPath === null) {
798
957
  return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
799
958
  }
@@ -828,7 +987,7 @@ function createBulkApprovalUpdates(ctx) {
828
987
  if (actualUrl === void 0) {
829
988
  return [Promise.resolve({ kind: "unresolved" })];
830
989
  }
831
- const snapshotPath = resolveApprovalTarget(ctx, test, lastRetry, imageName);
990
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
832
991
  if (snapshotPath === null) {
833
992
  return [Promise.resolve({ kind: "unresolved" })];
834
993
  }
@@ -891,7 +1050,7 @@ async function handleScreenshots(ctx, req) {
891
1050
  }
892
1051
  async function handleDist(ctx, req) {
893
1052
  const path = new URL(req.url).pathname.slice("/dist/".length);
894
- const filePath = (0, import_path4.join)(ctx.staticDir, path);
1053
+ const filePath = (0, import_path7.join)(ctx.staticDir, path);
895
1054
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
896
1055
  const file = await respondWithFile(filePath, contentType);
897
1056
  return file ?? new Response("Not Found", { status: 404 });
@@ -919,6 +1078,10 @@ function handleHttpRequest(ctx, req) {
919
1078
  if (pathname.startsWith("/api/images/")) {
920
1079
  return handleApiImages(req);
921
1080
  }
1081
+ const artifactResponse = handleArtifactRoute(ctx, req, pathname);
1082
+ if (artifactResponse !== null) {
1083
+ return artifactResponse;
1084
+ }
922
1085
  if (pathname.startsWith("/screenshots/")) {
923
1086
  return handleScreenshots(ctx, req);
924
1087
  }
@@ -940,12 +1103,13 @@ function createReportData(options) {
940
1103
  screenshotDir: options.screenshotDir ?? "./screenshots"
941
1104
  };
942
1105
  }
943
- function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
1106
+ function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
944
1107
  return {
945
1108
  reportData,
946
1109
  wsClients,
947
1110
  currentRunIds,
948
- saveReport
1111
+ saveReport,
1112
+ approvalRouting
949
1113
  };
950
1114
  }
951
1115
  async function loadReport(reportPath, reportData) {
@@ -1015,9 +1179,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
1015
1179
  handleTestEnd(ctx, parsed);
1016
1180
  break;
1017
1181
  }
1018
- case "run-end":
1019
- 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);
1020
1189
  break;
1190
+ }
1021
1191
  case "approve":
1022
1192
  handleApprove();
1023
1193
  break;
@@ -1030,7 +1200,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
1030
1200
  return async function handleWebSocketMessage(message) {
1031
1201
  try {
1032
1202
  const parsed = JSON.parse(message);
1033
- const wsMessage = safeParse(WebSocketMessageSchema, parsed);
1203
+ const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
1034
1204
  if (wsMessage === null) {
1035
1205
  console.error("Invalid WebSocket message: missing or invalid type", parsed);
1036
1206
  return;
@@ -1043,19 +1213,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1043
1213
  };
1044
1214
  }
1045
1215
  async function resolveStaticDir(staticDir) {
1046
- const currentDir = (0, import_path5.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1216
+ const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1047
1217
  const candidates = staticDir === void 0 ? [
1048
1218
  currentDir,
1049
- (0, import_path5.join)(currentDir, "dist"),
1050
- (0, import_path5.join)(currentDir, "..", "dist"),
1051
- (0, import_path5.join)(currentDir, "..", "..", "dist"),
1052
- (0, import_path5.join)(currentDir, ".."),
1053
- (0, import_path5.join)(currentDir, "..", "..")
1054
- ] : [staticDir, (0, import_path5.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")];
1055
1225
  const resolvedCandidates = await Promise.all(
1056
1226
  candidates.map(async (candidate) => ({
1057
1227
  candidate,
1058
- exists: await fileExists((0, import_path5.join)(candidate, "index.html"))
1228
+ exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
1059
1229
  }))
1060
1230
  );
1061
1231
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1066,15 +1236,19 @@ async function resolveStaticDir(staticDir) {
1066
1236
  }
1067
1237
  async function resolveReportPath(reportPath) {
1068
1238
  if (await isDirectory(reportPath)) {
1069
- return { reportFile: (0, import_path5.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1239
+ return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1070
1240
  }
1071
- return { reportFile: reportPath, offlineReportDir: (0, import_path5.dirname)(reportPath) };
1241
+ return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
1072
1242
  }
1073
1243
  function createRoutesContext(reportData, staticDir, saveReport, options) {
1244
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1245
+ (root) => root !== void 0 && root !== ""
1246
+ );
1074
1247
  return {
1075
1248
  reportData,
1076
1249
  staticDir,
1077
1250
  saveReport,
1251
+ artifactRoots,
1078
1252
  approvalRouting: {
1079
1253
  configDir: options.configDir ?? process.cwd(),
1080
1254
  playwrightTestDir: options.playwrightTestDir,
@@ -1096,7 +1270,7 @@ async function createServerApp(options = {}) {
1096
1270
  await writeJsonFile(reportFile, reportData);
1097
1271
  }
1098
1272
  const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
1099
- const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
1273
+ const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
1100
1274
  const handleRequest = (req) => handleHttpRequest(routesContext, req);
1101
1275
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1102
1276
  await loadReport(reportFile, reportData);
@@ -1286,7 +1460,7 @@ function attachWebSocketServer(server, app) {
1286
1460
  });
1287
1461
  }
1288
1462
  async function listen(server, port) {
1289
- await new Promise((resolve2, reject) => {
1463
+ await new Promise((resolve4, reject) => {
1290
1464
  const onError = (error) => {
1291
1465
  server.off("error", onError);
1292
1466
  reject(error);
@@ -1294,7 +1468,7 @@ async function listen(server, port) {
1294
1468
  server.on("error", onError);
1295
1469
  server.listen(port, () => {
1296
1470
  server.off("error", onError);
1297
- resolve2();
1471
+ resolve4();
1298
1472
  });
1299
1473
  });
1300
1474
  }