@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/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.0] - 2026-06-08
9
+
10
+ ### Added
11
+
12
+ - **resolver:** Expose playwrightAnonymousVisualName
13
+ - **resolver:** Add withResolvedVisualNames
14
+ - **server:** Add isPathWithinRoots allowlist helper
15
+ - **server:** Add allowlisted /file artifact route
16
+ - **server:** Add /baseline route, consolidate baseline resolution
17
+ - **server:** Wire artifactRoots and outputDir option
18
+ - Add isCI helper
19
+ - **report-utils:** Route absolute attachment paths to /file
20
+ - **server:** Enrich declared-only images with /baseline urls
21
+ - **reporter:** Add rewriteTestEndAttachments helper
22
+ - **reporter:** Gate screenshot copying and artifacts on CI
23
+ - **cli:** Add --output-dir for live native artifact serving
24
+
25
+ ### Changed
26
+
27
+ - **resolver:** Compute anonymous title list once
28
+
29
+ ### Documentation
30
+
31
+ - **spec:** CI-gated screenshot artifact handling design
32
+ - **plans:** Naming fix, live-mode serving, CI-gated copying
33
+ - **server:** Clarify outputDir is resolved against CWD
34
+
35
+ ### Fixed
36
+
37
+ - Apply web socket payloads directly to the client tree
38
+ - **reporter:** Use Playwright auto-name for unnamed screenshots
39
+ - **server:** Realpath-guard /file route and set nosniff header
40
+ - **server:** Guard /baseline decode, retry parsing, and read errors
41
+ - **server:** Exclude test source dir from /file allowlist
42
+ - **server:** Guard /baseline url parsing, decode testId, test nosniff
43
+ - **server:** Decode /file URLs in approval path
44
+ - **server:** Only enrich declared-only baselines for passed tests
45
+ - **server:** Enrich baselines only when the snapshot file exists
46
+ - **reporter:** Bound onEnd concurrency, fix retry attachments, stop CI queue growth
47
+
48
+ ### Styling
49
+
50
+ - **client:** Center-align image comparison views
51
+
52
+ ### Testing
53
+
54
+ - **report-state:** Assert no phantom entry for finalized unnamed screenshot
55
+ - **report-state:** Make phantom-entry guard falsifiable
56
+ - Document phantom fixture divergence and cover multi-occurrence rewrite
57
+ - **server:** Cover raw-traversal and empty-roots cases for isPathWithinRoots
58
+ ## [0.0.9] - 2026-06-05
59
+
60
+ ### Fixed
61
+
62
+ - Remove file watcher that interfered with live updates
8
63
  ## [0.0.8] - 2026-06-05
9
64
 
10
65
  ### Fixed
@@ -1,20 +1,21 @@
1
1
  import {
2
2
  ApproveRequestBodySchema,
3
+ IncomingWebSocketMessageSchema,
3
4
  LoadedReportDataSchema,
4
5
  OfflineReportSchema,
6
+ RunEndDataSchema,
5
7
  TestBeginDataSchema,
6
8
  TestEndDataSchema,
7
- WebSocketMessageSchema,
8
9
  applyTestBeginEvent,
9
10
  applyTestEndEvent,
10
11
  createMutableReportState,
11
12
  finalizeRunEvent,
12
13
  resolveBaselineTargets,
13
14
  safeParse
14
- } from "./chunk-EJRLZZ22.js";
15
+ } from "./chunk-43JLQN36.js";
15
16
 
16
17
  // src/server/app.ts
17
- import { dirname as dirname3, join as join4 } from "path";
18
+ import { dirname as dirname3, join as join3 } from "path";
18
19
  import { fileURLToPath } from "url";
19
20
  import pLimit from "p-limit";
20
21
 
@@ -157,7 +158,10 @@ async function respondWithFile(filePath, contentType) {
157
158
  try {
158
159
  const file = await readFile(filePath);
159
160
  const resolvedContentType = contentType ?? inferContentType(filePath);
160
- const headers = resolvedContentType === void 0 ? void 0 : { "Content-Type": resolvedContentType };
161
+ const headers = { "X-Content-Type-Options": "nosniff" };
162
+ if (resolvedContentType !== void 0) {
163
+ headers["Content-Type"] = resolvedContentType;
164
+ }
161
165
  return new Response(file, { headers });
162
166
  } catch (error) {
163
167
  if (isFileNotFound(error)) {
@@ -167,166 +171,212 @@ async function respondWithFile(filePath, contentType) {
167
171
  }
168
172
  }
169
173
 
174
+ // src/server/handlers.ts
175
+ import { existsSync as existsSync2 } from "fs";
176
+
177
+ // src/server/artifact-routes.ts
178
+ import { existsSync } from "fs";
179
+ import { realpath } from "fs/promises";
180
+ import { dirname as dirname2, resolve as resolve2 } from "path";
181
+
170
182
  // src/server/utils.ts
183
+ import { isAbsolute, relative, resolve, sep } from "path";
184
+ var LIVE_UPDATES_WEBSOCKET_PATH = "/";
171
185
  function broadcastToBrowsers(wsClients, msg) {
172
186
  const payload = JSON.stringify(msg);
173
187
  wsClients.forEach((ws) => {
174
188
  ws.send(payload);
175
189
  });
176
190
  }
191
+ function isWebSocketUpgradeRequest(req) {
192
+ return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
193
+ }
194
+ function isPathWithinRoots(target, roots) {
195
+ const resolvedTarget = resolve(target);
196
+ return roots.some((root) => {
197
+ const rel = relative(resolve(root), resolvedTarget);
198
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
199
+ });
200
+ }
201
+
202
+ // src/server/artifact-routes.ts
203
+ async function realpathOrNull(path) {
204
+ try {
205
+ return await realpath(path);
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+ async function handleFile(ctx, req) {
211
+ const notFound = () => new Response("Not Found", { status: 404 });
212
+ let decodedPath;
213
+ try {
214
+ decodedPath = resolve2(decodeURIComponent(new URL(req.url).pathname.slice("/file/".length)));
215
+ } catch {
216
+ return notFound();
217
+ }
218
+ const realTarget = await realpathOrNull(decodedPath);
219
+ if (realTarget === null) {
220
+ return notFound();
221
+ }
222
+ const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull(resolve2(root))))).filter(
223
+ (root) => root !== null
224
+ );
225
+ if (!isPathWithinRoots(realTarget, realRoots)) {
226
+ return notFound();
227
+ }
228
+ try {
229
+ const file = await respondWithFile(realTarget);
230
+ return file ?? notFound();
231
+ } catch {
232
+ return notFound();
233
+ }
234
+ }
235
+ function reporterTitlePath(test) {
236
+ const testFile = test.location?.file;
237
+ return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
238
+ }
239
+ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
240
+ const testFile = test.location?.file;
241
+ const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
242
+ if (routing === void 0 || testFile === void 0 || declaration === void 0) {
243
+ return null;
244
+ }
245
+ const targets = resolveBaselineTargets({
246
+ testFile,
247
+ reporterTitlePath: reporterTitlePath(test),
248
+ declarations: [declaration],
249
+ config: {
250
+ configDir: routing.configDir,
251
+ testDir: routing.playwrightTestDir ?? dirname2(testFile),
252
+ snapshotDir: routing.playwrightSnapshotDir ?? dirname2(testFile),
253
+ projectName: test.browser,
254
+ snapshotSuffix: process.platform,
255
+ snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
256
+ toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
257
+ },
258
+ snapshotPathExists: existsSync
259
+ });
260
+ return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
261
+ }
262
+ async function handleBaseline(ctx, req) {
263
+ const notFound = () => new Response("Not Found", { status: 404 });
264
+ let testId;
265
+ let retry;
266
+ let visualName;
267
+ try {
268
+ const segments = new URL(req.url).pathname.slice("/baseline/".length).split("/");
269
+ const [rawTestId, retryRaw, ...visualNameParts] = segments;
270
+ if (rawTestId === void 0 || retryRaw === void 0 || !/^\d+$/.test(retryRaw) || visualNameParts.length === 0) {
271
+ return notFound();
272
+ }
273
+ testId = decodeURIComponent(rawTestId);
274
+ retry = Number(retryRaw);
275
+ visualName = decodeURIComponent(visualNameParts.join("/"));
276
+ } catch {
277
+ return notFound();
278
+ }
279
+ const test = ctx.reportData.tests[testId];
280
+ if (test === void 0) {
281
+ return notFound();
282
+ }
283
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
284
+ if (snapshotPath === null) {
285
+ return notFound();
286
+ }
287
+ try {
288
+ const file = await respondWithFile(snapshotPath);
289
+ return file ?? notFound();
290
+ } catch {
291
+ return notFound();
292
+ }
293
+ }
294
+ function handleArtifactRoute(ctx, req, pathname) {
295
+ if (pathname.startsWith("/file/")) {
296
+ return handleFile(ctx, req);
297
+ }
298
+ if (pathname.startsWith("/baseline/")) {
299
+ return handleBaseline(ctx, req);
300
+ }
301
+ return null;
302
+ }
177
303
 
178
304
  // src/server/handlers.ts
179
305
  function handleTestBegin(ctx, data) {
180
306
  const test = applyTestBeginEvent(ctx, data);
181
307
  ctx.reportData.isRunning = true;
182
308
  console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
183
- broadcastToBrowsers(ctx.wsClients, { type: "test-begin", data });
309
+ const message = { type: "test-begin", data: test };
310
+ broadcastToBrowsers(ctx.wsClients, message);
311
+ }
312
+ function enrichDeclaredBaselines(ctx, test) {
313
+ const retry = (test.results?.length ?? 0) - 1;
314
+ const images = test.results?.[retry]?.images;
315
+ if (retry < 0 || images === void 0) {
316
+ return;
317
+ }
318
+ for (const [visualName, image] of Object.entries(images)) {
319
+ if (image === void 0 || image.source !== "declared-only") {
320
+ continue;
321
+ }
322
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, visualName);
323
+ if (snapshotPath === null || !existsSync2(snapshotPath)) {
324
+ continue;
325
+ }
326
+ image.expect = `/baseline/${encodeURIComponent(test.id)}/${retry}/${encodeURIComponent(visualName)}`;
327
+ image.source = "baseline-only";
328
+ }
184
329
  }
185
330
  function handleTestEnd(ctx, data) {
186
331
  const result = applyTestEndEvent(ctx, data);
187
- if (result !== null) {
188
- const { test, diffCount } = result;
189
- const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
190
- const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
191
- const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
192
- const errNote = data.error !== null && data.error !== void 0 ? `
193
- Error: ${data.error}` : "";
194
- console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
332
+ if (result === null) {
333
+ console.error("[Server] test-end for unknown test id:", data.id);
334
+ return;
195
335
  }
196
- broadcastToBrowsers(ctx.wsClients, { type: "test-update", data });
336
+ const { test, diffCount } = result;
337
+ if (data.status === "passed") {
338
+ enrichDeclaredBaselines(ctx, test);
339
+ }
340
+ const icon = data.status === "passed" ? "\u2713" : data.status === "skipped" ? "\u2013" : "\u2717";
341
+ const dur = data.duration === null || data.duration === void 0 ? "" : ` (${data.duration}ms)`;
342
+ const diffNote = diffCount > 0 ? ` [${diffCount} diff(s)]` : "";
343
+ const errNote = data.error !== null && data.error !== void 0 ? `
344
+ Error: ${data.error}` : "";
345
+ console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
346
+ const message = { type: "test-update", data: test };
347
+ broadcastToBrowsers(ctx.wsClients, message);
197
348
  }
198
349
  async function handleRunEnd(ctx, data) {
350
+ const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
199
351
  const { passed, failed, pending } = finalizeRunEvent(ctx);
200
352
  await ctx.saveReport();
201
353
  console.log(`
202
354
  Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
203
- broadcastToBrowsers(ctx.wsClients, { type: "run-end", data });
355
+ const message = {
356
+ type: "run-end",
357
+ data: { status: data.status, removedTestIds }
358
+ };
359
+ broadcastToBrowsers(ctx.wsClients, message);
204
360
  }
205
361
  function handleApprove() {
206
362
  console.log("[Server] Received approve message via WebSocket (handled via HTTP API)");
207
363
  }
208
364
  function handleSync(ctx) {
209
365
  console.log("[Server] Received sync message");
210
- broadcastToBrowsers(ctx.wsClients, { type: "sync", data: ctx.reportData });
211
- }
212
-
213
- // src/server/report-watch.ts
214
- import { readdir as readdir2, stat as stat2 } from "fs/promises";
215
- import { join as join2 } from "path";
216
- var OFFLINE_REPORT_FILE_PATTERN2 = /^crvy-rprtr(?:-\d+)?\.json$/;
217
- function createDebouncedRefresh(reload, delayMs = 50) {
218
- let timer = null;
219
- return () => {
220
- if (timer !== null) {
221
- clearTimeout(timer);
222
- }
223
- timer = setTimeout(() => {
224
- timer = null;
225
- void reload();
226
- }, delayMs);
227
- };
228
- }
229
- function isFileNotFound2(error) {
230
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
231
- }
232
- async function describeFile(filePath, label) {
233
- try {
234
- const fileStats = await stat2(filePath);
235
- return `${label}:${fileStats.size}:${fileStats.mtimeMs}`;
236
- } catch (error) {
237
- if (isFileNotFound2(error)) {
238
- return null;
366
+ const message = {
367
+ type: "sync",
368
+ data: {
369
+ tests: ctx.reportData.tests,
370
+ isUpdateMode: ctx.reportData.isUpdateMode
239
371
  }
240
- throw error;
241
- }
242
- }
243
- async function createOfflineFingerprint(offlineReportDir) {
244
- if (!await isDirectory(offlineReportDir)) {
245
- return "offline:missing";
246
- }
247
- try {
248
- const entries = await readdir2(offlineReportDir, { withFileTypes: true });
249
- const relevantEntries = entries.filter(
250
- (entry) => entry.isFile() && (entry.name === "report.json" || OFFLINE_REPORT_FILE_PATTERN2.test(entry.name))
251
- ).sort((left, right) => left.name.localeCompare(right.name));
252
- const parts = await Promise.all(
253
- relevantEntries.map((entry) => describeFile(join2(offlineReportDir, entry.name), entry.name))
254
- );
255
- return `offline:${parts.filter((part) => part !== null).join("|")}`;
256
- } catch (error) {
257
- const errorMsg = error instanceof Error ? error.message : String(error);
258
- console.warn(`[Server] Unable to scan ${offlineReportDir}: ${errorMsg}`);
259
- return "offline:error";
260
- }
261
- }
262
- async function createScreenshotFingerprint(screenshotDir, relativePath = "") {
263
- const directoryPath = relativePath === "" ? screenshotDir : join2(screenshotDir, relativePath);
264
- if (!await isDirectory(directoryPath)) {
265
- return relativePath === "" ? ["screenshots:missing"] : [];
266
- }
267
- try {
268
- const entries = await readdir2(directoryPath, { withFileTypes: true });
269
- const parts = await Promise.all(
270
- entries.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
271
- const entryRelativePath = relativePath === "" ? entry.name : join2(relativePath, entry.name);
272
- if (entry.isDirectory()) {
273
- return createScreenshotFingerprint(screenshotDir, entryRelativePath);
274
- }
275
- return describeFile(join2(screenshotDir, entryRelativePath), entryRelativePath);
276
- })
277
- );
278
- return parts.flat().filter((part) => part !== null);
279
- } catch (error) {
280
- if (isFileNotFound2(error)) {
281
- return relativePath === "" ? ["screenshots:missing"] : [];
282
- }
283
- const errorMsg = error instanceof Error ? error.message : String(error);
284
- console.warn(`[Server] Unable to scan ${directoryPath}: ${errorMsg}`);
285
- return relativePath === "" ? ["screenshots:error"] : [];
286
- }
287
- }
288
- async function createArtifactsFingerprint(options) {
289
- const [offlineFingerprint, screenshotFingerprint] = await Promise.all([
290
- createOfflineFingerprint(options.offlineReportDir),
291
- createScreenshotFingerprint(options.screenshotDir)
292
- ]);
293
- return `${offlineFingerprint}::${screenshotFingerprint.join("|")}`;
294
- }
295
- async function watchReportArtifacts(options) {
296
- let fingerprint = await createArtifactsFingerprint(options);
297
- let isPolling = false;
298
- const interval = setInterval(() => {
299
- if (isPolling) {
300
- return;
301
- }
302
- isPolling = true;
303
- void createArtifactsFingerprint(options).then((nextFingerprint) => {
304
- if (nextFingerprint === fingerprint) {
305
- return;
306
- }
307
- fingerprint = nextFingerprint;
308
- options.scheduleRefresh();
309
- }).catch((error) => {
310
- const errorMsg = error instanceof Error ? error.message : String(error);
311
- console.warn(`[Server] Artifact refresh poll failed: ${errorMsg}`);
312
- }).finally(() => {
313
- isPolling = false;
314
- });
315
- }, 100);
316
- return () => {
317
- clearInterval(interval);
318
372
  };
373
+ broadcastToBrowsers(ctx.wsClients, message);
319
374
  }
320
375
 
321
376
  // src/server/routes.ts
322
- import { existsSync } from "fs";
323
- import { dirname as dirname2, join as join3 } from "path";
324
- var LIVE_UPDATES_WEBSOCKET_PATH = "/";
325
- function isWebSocketUpgradeRequest(req) {
326
- return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
327
- }
377
+ import { join as join2 } from "path";
328
378
  async function handleRoot(ctx) {
329
- const html = await respondWithFile(join3(ctx.staticDir, "index.html"), "text/html");
379
+ const html = await respondWithFile(join2(ctx.staticDir, "index.html"), "text/html");
330
380
  return html ?? new Response("Not Found", { status: 404 });
331
381
  }
332
382
  async function handleAppCss() {
@@ -345,34 +395,13 @@ function handleApiReport(ctx) {
345
395
  }
346
396
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
347
397
  function actualPathFromUrl(ctx, actualUrl) {
348
- return actualUrl.startsWith("/screenshots/") ? join3(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length)) : actualUrl;
349
- }
350
- function reporterTitlePath(test) {
351
- const testFile = test.location?.file;
352
- return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
353
- }
354
- function resolveApprovalTarget(ctx, test, retry, imageName) {
355
- const testFile = test.location?.file;
356
- const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
357
- if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
358
- return null;
398
+ if (actualUrl.startsWith("/screenshots/")) {
399
+ return join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
359
400
  }
360
- const targets = resolveBaselineTargets({
361
- testFile,
362
- reporterTitlePath: reporterTitlePath(test),
363
- declarations: [declaration],
364
- config: {
365
- configDir: ctx.approvalRouting.configDir,
366
- testDir: ctx.approvalRouting.playwrightTestDir ?? dirname2(testFile),
367
- snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? dirname2(testFile),
368
- projectName: test.browser,
369
- snapshotSuffix: process.platform,
370
- snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
371
- toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
372
- },
373
- snapshotPathExists: existsSync
374
- });
375
- return targets.length === 1 ? targets[0]?.snapshotPath ?? null : null;
401
+ if (actualUrl.startsWith("/file/")) {
402
+ return decodeURIComponent(actualUrl.slice("/file/".length));
403
+ }
404
+ return actualUrl;
376
405
  }
377
406
  async function handleApiApprove(ctx, req) {
378
407
  try {
@@ -391,7 +420,7 @@ async function handleApiApprove(ctx, req) {
391
420
  if (actualUrl === void 0) {
392
421
  return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
393
422
  }
394
- const snapshotPath = resolveApprovalTarget(ctx, test, retry, image);
423
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
395
424
  if (snapshotPath === null) {
396
425
  return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
397
426
  }
@@ -426,7 +455,7 @@ function createBulkApprovalUpdates(ctx) {
426
455
  if (actualUrl === void 0) {
427
456
  return [Promise.resolve({ kind: "unresolved" })];
428
457
  }
429
- const snapshotPath = resolveApprovalTarget(ctx, test, lastRetry, imageName);
458
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
430
459
  if (snapshotPath === null) {
431
460
  return [Promise.resolve({ kind: "unresolved" })];
432
461
  }
@@ -489,7 +518,7 @@ async function handleScreenshots(ctx, req) {
489
518
  }
490
519
  async function handleDist(ctx, req) {
491
520
  const path = new URL(req.url).pathname.slice("/dist/".length);
492
- const filePath = join3(ctx.staticDir, path);
521
+ const filePath = join2(ctx.staticDir, path);
493
522
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
494
523
  const file = await respondWithFile(filePath, contentType);
495
524
  return file ?? new Response("Not Found", { status: 404 });
@@ -517,6 +546,10 @@ function handleHttpRequest(ctx, req) {
517
546
  if (pathname.startsWith("/api/images/")) {
518
547
  return handleApiImages(req);
519
548
  }
549
+ const artifactResponse = handleArtifactRoute(ctx, req, pathname);
550
+ if (artifactResponse !== null) {
551
+ return artifactResponse;
552
+ }
520
553
  if (pathname.startsWith("/screenshots/")) {
521
554
  return handleScreenshots(ctx, req);
522
555
  }
@@ -537,12 +570,13 @@ function createReportData(options) {
537
570
  screenshotDir: options.screenshotDir ?? "./screenshots"
538
571
  };
539
572
  }
540
- function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
573
+ function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
541
574
  return {
542
575
  reportData,
543
576
  wsClients,
544
577
  currentRunIds,
545
- saveReport
578
+ saveReport,
579
+ approvalRouting
546
580
  };
547
581
  }
548
582
  async function loadReport(reportPath, reportData) {
@@ -592,10 +626,6 @@ async function loadOfflineReports(offlineReportDir, reportData) {
592
626
  screenshotsBaseUrl: "/screenshots/"
593
627
  });
594
628
  }
595
- function resetReloadableReportData(reportData) {
596
- reportData.tests = {};
597
- reportData.isUpdateMode = false;
598
- }
599
629
  async function handleParsedWebSocketMessage(ctx, msg) {
600
630
  switch (msg.type) {
601
631
  case "test-begin": {
@@ -616,9 +646,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
616
646
  handleTestEnd(ctx, parsed);
617
647
  break;
618
648
  }
619
- case "run-end":
620
- await handleRunEnd(ctx, msg.data);
649
+ case "run-end": {
650
+ const parsed = safeParse(RunEndDataSchema, msg.data);
651
+ if (parsed === null) {
652
+ console.error("Invalid run-end message data", msg.data);
653
+ break;
654
+ }
655
+ await handleRunEnd(ctx, parsed);
621
656
  break;
657
+ }
622
658
  case "approve":
623
659
  handleApprove();
624
660
  break;
@@ -631,7 +667,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
631
667
  return async function handleWebSocketMessage(message) {
632
668
  try {
633
669
  const parsed = JSON.parse(message);
634
- const wsMessage = safeParse(WebSocketMessageSchema, parsed);
670
+ const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
635
671
  if (wsMessage === null) {
636
672
  console.error("Invalid WebSocket message: missing or invalid type", parsed);
637
673
  return;
@@ -647,16 +683,16 @@ async function resolveStaticDir(staticDir) {
647
683
  const currentDir = dirname3(fileURLToPath(import.meta.url));
648
684
  const candidates = staticDir === void 0 ? [
649
685
  currentDir,
650
- join4(currentDir, "dist"),
651
- join4(currentDir, "..", "dist"),
652
- join4(currentDir, "..", "..", "dist"),
653
- join4(currentDir, ".."),
654
- join4(currentDir, "..", "..")
655
- ] : [staticDir, join4(staticDir, "dist")];
686
+ join3(currentDir, "dist"),
687
+ join3(currentDir, "..", "dist"),
688
+ join3(currentDir, "..", "..", "dist"),
689
+ join3(currentDir, ".."),
690
+ join3(currentDir, "..", "..")
691
+ ] : [staticDir, join3(staticDir, "dist")];
656
692
  const resolvedCandidates = await Promise.all(
657
693
  candidates.map(async (candidate) => ({
658
694
  candidate,
659
- exists: await fileExists(join4(candidate, "index.html"))
695
+ exists: await fileExists(join3(candidate, "index.html"))
660
696
  }))
661
697
  );
662
698
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -667,15 +703,19 @@ async function resolveStaticDir(staticDir) {
667
703
  }
668
704
  async function resolveReportPath(reportPath) {
669
705
  if (await isDirectory(reportPath)) {
670
- return { reportFile: join4(reportPath, "report.json"), offlineReportDir: reportPath };
706
+ return { reportFile: join3(reportPath, "report.json"), offlineReportDir: reportPath };
671
707
  }
672
708
  return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
673
709
  }
674
710
  function createRoutesContext(reportData, staticDir, saveReport, options) {
711
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
712
+ (root) => root !== void 0 && root !== ""
713
+ );
675
714
  return {
676
715
  reportData,
677
716
  staticDir,
678
717
  saveReport,
718
+ artifactRoots,
679
719
  approvalRouting: {
680
720
  configDir: options.configDir ?? process.cwd(),
681
721
  playwrightTestDir: options.playwrightTestDir,
@@ -697,25 +737,16 @@ async function createServerApp(options = {}) {
697
737
  await writeJsonFile(reportFile, reportData);
698
738
  }
699
739
  const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
700
- const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
740
+ const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
701
741
  const handleRequest = (req) => handleHttpRequest(routesContext, req);
702
742
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
703
- const reloadFromDisk = async () => {
704
- resetReloadableReportData(reportData);
705
- await loadReport(reportFile, reportData);
706
- await loadOfflineReports(offlineReportDir, reportData);
707
- broadcastToBrowsers(wsClients, { type: "sync", data: reportData });
708
- };
709
- await reloadFromDisk();
710
- const close = await watchReportArtifacts({
711
- offlineReportDir,
712
- screenshotDir: reportData.screenshotDir,
713
- scheduleRefresh: createDebouncedRefresh(reloadFromDisk)
714
- });
743
+ await loadReport(reportFile, reportData);
744
+ await loadOfflineReports(offlineReportDir, reportData);
715
745
  return {
716
746
  port,
717
747
  wsClients,
718
- close,
748
+ close: () => {
749
+ },
719
750
  handleRequest,
720
751
  handleWebSocketMessage
721
752
  };
@@ -896,7 +927,7 @@ function attachWebSocketServer(server, app) {
896
927
  });
897
928
  }
898
929
  async function listen(server, port) {
899
- await new Promise((resolve, reject) => {
930
+ await new Promise((resolve3, reject) => {
900
931
  const onError = (error) => {
901
932
  server.off("error", onError);
902
933
  reject(error);
@@ -904,7 +935,7 @@ async function listen(server, port) {
904
935
  server.on("error", onError);
905
936
  server.listen(port, () => {
906
937
  server.off("error", onError);
907
- resolve();
938
+ resolve3();
908
939
  });
909
940
  });
910
941
  }