@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/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ 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
8
58
  ## [0.0.9] - 2026-06-05
9
59
 
10
60
  ### Fixed
@@ -1,17 +1,18 @@
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
18
  import { dirname as dirname3, join as join3 } from "path";
@@ -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,56 +171,210 @@ 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;
335
+ }
336
+ const { test, diffCount } = result;
337
+ if (data.status === "passed") {
338
+ enrichDeclaredBaselines(ctx, test);
195
339
  }
196
- broadcastToBrowsers(ctx.wsClients, { type: "test-update", data });
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 });
366
+ const message = {
367
+ type: "sync",
368
+ data: {
369
+ tests: ctx.reportData.tests,
370
+ isUpdateMode: ctx.reportData.isUpdateMode
371
+ }
372
+ };
373
+ broadcastToBrowsers(ctx.wsClients, message);
211
374
  }
212
375
 
213
376
  // src/server/routes.ts
214
- import { existsSync } from "fs";
215
- import { dirname as dirname2, join as join2 } from "path";
216
- var LIVE_UPDATES_WEBSOCKET_PATH = "/";
217
- function isWebSocketUpgradeRequest(req) {
218
- return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
219
- }
377
+ import { join as join2 } from "path";
220
378
  async function handleRoot(ctx) {
221
379
  const html = await respondWithFile(join2(ctx.staticDir, "index.html"), "text/html");
222
380
  return html ?? new Response("Not Found", { status: 404 });
@@ -237,34 +395,13 @@ function handleApiReport(ctx) {
237
395
  }
238
396
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
239
397
  function actualPathFromUrl(ctx, actualUrl) {
240
- return actualUrl.startsWith("/screenshots/") ? join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length)) : actualUrl;
241
- }
242
- function reporterTitlePath(test) {
243
- const testFile = test.location?.file;
244
- return ["", test.browser, testFile ?? "", ...test.titlePath, test.title];
245
- }
246
- function resolveApprovalTarget(ctx, test, retry, imageName) {
247
- const testFile = test.location?.file;
248
- const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
249
- if (ctx.approvalRouting === void 0 || testFile === void 0 || declaration === void 0) {
250
- return null;
398
+ if (actualUrl.startsWith("/screenshots/")) {
399
+ return join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
251
400
  }
252
- const targets = resolveBaselineTargets({
253
- testFile,
254
- reporterTitlePath: reporterTitlePath(test),
255
- declarations: [declaration],
256
- config: {
257
- configDir: ctx.approvalRouting.configDir,
258
- testDir: ctx.approvalRouting.playwrightTestDir ?? dirname2(testFile),
259
- snapshotDir: ctx.approvalRouting.playwrightSnapshotDir ?? dirname2(testFile),
260
- projectName: test.browser,
261
- snapshotSuffix: process.platform,
262
- snapshotPathTemplate: ctx.approvalRouting.playwrightSnapshotPathTemplate,
263
- toHaveScreenshotPathTemplate: ctx.approvalRouting.playwrightToHaveScreenshotPathTemplate
264
- },
265
- snapshotPathExists: existsSync
266
- });
267
- 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;
268
405
  }
269
406
  async function handleApiApprove(ctx, req) {
270
407
  try {
@@ -283,7 +420,7 @@ async function handleApiApprove(ctx, req) {
283
420
  if (actualUrl === void 0) {
284
421
  return Response.json({ success: false, error: "Actual image not found" }, { status: 409 });
285
422
  }
286
- const snapshotPath = resolveApprovalTarget(ctx, test, retry, image);
423
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, retry, image);
287
424
  if (snapshotPath === null) {
288
425
  return Response.json({ success: false, error: APPROVAL_TARGET_ERROR }, { status: 409 });
289
426
  }
@@ -318,7 +455,7 @@ function createBulkApprovalUpdates(ctx) {
318
455
  if (actualUrl === void 0) {
319
456
  return [Promise.resolve({ kind: "unresolved" })];
320
457
  }
321
- const snapshotPath = resolveApprovalTarget(ctx, test, lastRetry, imageName);
458
+ const snapshotPath = resolveBaselineSnapshotPath(ctx.approvalRouting, test, lastRetry, imageName);
322
459
  if (snapshotPath === null) {
323
460
  return [Promise.resolve({ kind: "unresolved" })];
324
461
  }
@@ -409,6 +546,10 @@ function handleHttpRequest(ctx, req) {
409
546
  if (pathname.startsWith("/api/images/")) {
410
547
  return handleApiImages(req);
411
548
  }
549
+ const artifactResponse = handleArtifactRoute(ctx, req, pathname);
550
+ if (artifactResponse !== null) {
551
+ return artifactResponse;
552
+ }
412
553
  if (pathname.startsWith("/screenshots/")) {
413
554
  return handleScreenshots(ctx, req);
414
555
  }
@@ -429,12 +570,13 @@ function createReportData(options) {
429
570
  screenshotDir: options.screenshotDir ?? "./screenshots"
430
571
  };
431
572
  }
432
- function createHandlerContext(reportData, wsClients, currentRunIds, saveReport) {
573
+ function createHandlerContext(reportData, wsClients, currentRunIds, saveReport, approvalRouting) {
433
574
  return {
434
575
  reportData,
435
576
  wsClients,
436
577
  currentRunIds,
437
- saveReport
578
+ saveReport,
579
+ approvalRouting
438
580
  };
439
581
  }
440
582
  async function loadReport(reportPath, reportData) {
@@ -504,9 +646,15 @@ async function handleParsedWebSocketMessage(ctx, msg) {
504
646
  handleTestEnd(ctx, parsed);
505
647
  break;
506
648
  }
507
- case "run-end":
508
- 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);
509
656
  break;
657
+ }
510
658
  case "approve":
511
659
  handleApprove();
512
660
  break;
@@ -519,7 +667,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
519
667
  return async function handleWebSocketMessage(message) {
520
668
  try {
521
669
  const parsed = JSON.parse(message);
522
- const wsMessage = safeParse(WebSocketMessageSchema, parsed);
670
+ const wsMessage = safeParse(IncomingWebSocketMessageSchema, parsed);
523
671
  if (wsMessage === null) {
524
672
  console.error("Invalid WebSocket message: missing or invalid type", parsed);
525
673
  return;
@@ -560,10 +708,14 @@ async function resolveReportPath(reportPath) {
560
708
  return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
561
709
  }
562
710
  function createRoutesContext(reportData, staticDir, saveReport, options) {
711
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
712
+ (root) => root !== void 0 && root !== ""
713
+ );
563
714
  return {
564
715
  reportData,
565
716
  staticDir,
566
717
  saveReport,
718
+ artifactRoots,
567
719
  approvalRouting: {
568
720
  configDir: options.configDir ?? process.cwd(),
569
721
  playwrightTestDir: options.playwrightTestDir,
@@ -585,7 +737,7 @@ async function createServerApp(options = {}) {
585
737
  await writeJsonFile(reportFile, reportData);
586
738
  }
587
739
  const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
588
- const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport);
740
+ const getHandlerContext = () => createHandlerContext(reportData, wsClients, currentRunIds, saveReport, routesContext.approvalRouting);
589
741
  const handleRequest = (req) => handleHttpRequest(routesContext, req);
590
742
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
591
743
  await loadReport(reportFile, reportData);
@@ -775,7 +927,7 @@ function attachWebSocketServer(server, app) {
775
927
  });
776
928
  }
777
929
  async function listen(server, port) {
778
- await new Promise((resolve, reject) => {
930
+ await new Promise((resolve3, reject) => {
779
931
  const onError = (error) => {
780
932
  server.off("error", onError);
781
933
  reject(error);
@@ -783,7 +935,7 @@ async function listen(server, port) {
783
935
  server.on("error", onError);
784
936
  server.listen(port, () => {
785
937
  server.off("error", onError);
786
- resolve();
938
+ resolve3();
787
939
  });
788
940
  });
789
941
  }
@@ -131,9 +131,15 @@ function resolveNamedTarget(input, declaration) {
131
131
  function reporterTitlesWithoutProjectAndFile(reporterTitlePath) {
132
132
  return reporterTitlePath.slice(3).filter((part) => part !== "");
133
133
  }
134
+ function anonymousNameFromTitles(titles, occurrenceIndex) {
135
+ return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
136
+ }
134
137
  function anonymousName(reporterTitlePath, occurrenceIndex) {
135
- const rawAnonymousName = `${reporterTitlesWithoutProjectAndFile(reporterTitlePath).join(" ")} ${occurrenceIndex}.png`;
136
- return sanitizeFilePathBeforeExtension(trimLongString(rawAnonymousName), ".png");
138
+ return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath), occurrenceIndex);
139
+ }
140
+ function playwrightAnonymousVisualName(reporterTitlePath, occurrenceIndex) {
141
+ const titles = reporterTitlesWithoutProjectAndFile(reporterTitlePath);
142
+ return titles.length === 0 ? null : removeExtension(anonymousNameFromTitles(titles, occurrenceIndex), ".png");
137
143
  }
138
144
  function resolveTarget(input, declaration) {
139
145
  switch (declaration.kind) {
@@ -147,6 +153,15 @@ function resolveTarget(input, declaration) {
147
153
  }
148
154
  }
149
155
  }
156
+ function withResolvedVisualNames(declarations, reporterTitlePath) {
157
+ return declarations.map((declaration) => {
158
+ if (declaration.kind !== "unnamed") {
159
+ return declaration;
160
+ }
161
+ const resolvedName = playwrightAnonymousVisualName(reporterTitlePath, declaration.occurrenceIndex);
162
+ return resolvedName === null ? declaration : { ...declaration, visualName: resolvedName };
163
+ });
164
+ }
150
165
  function resolveBaselineTargets(input) {
151
166
  return input.declarations.flatMap((declaration) => {
152
167
  const resolvedTarget = resolveTarget(input, declaration);
@@ -224,10 +239,29 @@ var CrvyRprtrSuiteSchema = z.lazy(
224
239
  children: z.record(z.string(), z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
225
240
  })
226
241
  );
227
- var WebSocketMessageSchema = z.object({
242
+ var IncomingWebSocketMessageSchema = z.object({
228
243
  type: z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
229
244
  data: z.unknown()
230
245
  });
246
+ var WebSocketMessageSchema = z.discriminatedUnion("type", [
247
+ z.object({ type: z.literal("test-begin"), data: TestDataSchema }),
248
+ z.object({ type: z.literal("test-update"), data: TestDataSchema }),
249
+ z.object({
250
+ type: z.literal("run-end"),
251
+ data: z.object({
252
+ status: z.enum(["passed", "failed", "skipped"]),
253
+ removedTestIds: z.array(z.string())
254
+ })
255
+ }),
256
+ z.object({
257
+ type: z.literal("sync"),
258
+ data: z.object({
259
+ tests: z.record(z.string(), TestDataSchema),
260
+ isUpdateMode: z.boolean().optional()
261
+ })
262
+ }),
263
+ z.object({ type: z.literal("approve"), data: z.unknown() })
264
+ ]);
231
265
  var TestBeginDataSchema = z.object({
232
266
  id: z.string(),
233
267
  title: z.string(),
@@ -247,6 +281,9 @@ var TestEndDataSchema = z.object({
247
281
  error: z.string().optional(),
248
282
  duration: z.number().optional()
249
283
  });
284
+ var RunEndDataSchema = z.object({
285
+ status: z.enum(["passed", "failed", "skipped"])
286
+ });
250
287
  var ReportDataSchema = z.object({
251
288
  isRunning: z.boolean(),
252
289
  tests: z.record(z.string(), TestDataSchema),
@@ -297,6 +334,7 @@ function safeParse(schema, data) {
297
334
  }
298
335
 
299
336
  // src/report-utils.ts
337
+ import { isAbsolute } from "path";
300
338
  function normalizeScreenshotsBaseUrl(baseUrl) {
301
339
  return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
302
340
  }
@@ -341,7 +379,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
341
379
  const role = match[2];
342
380
  if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
343
381
  images[baseName] ??= {};
344
- const url = `${baseUrl}${attachment.path}`;
382
+ const url = isAbsolute(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
345
383
  const img = images[baseName];
346
384
  if (img !== null && img !== void 0) {
347
385
  if (role === "actual") img.actual = url;
@@ -488,13 +526,15 @@ export {
488
526
  applyTestBeginEvent,
489
527
  applyTestEndEvent,
490
528
  finalizeRunEvent,
491
- WebSocketMessageSchema,
529
+ IncomingWebSocketMessageSchema,
492
530
  TestBeginDataSchema,
493
531
  TestEndDataSchema,
532
+ RunEndDataSchema,
494
533
  LoadedReportDataSchema,
495
534
  OfflineReportSchema,
496
535
  ApproveRequestBodySchema,
497
536
  ClientBootstrapDataSchema,
498
537
  safeParse,
538
+ withResolvedVisualNames,
499
539
  resolveBaselineTargets
500
540
  };
package/dist/ci.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function isCI(env?: Record<string, string | undefined>): boolean;
2
+ //# sourceMappingURL=ci.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci.d.ts","sourceRoot":"","sources":["../src/ci.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,OAAO,CAGnF"}
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,7 @@ interface ResolvedCliOptions extends ServerOptions {
4
4
  port: number;
5
5
  screenshotDir: string;
6
6
  reportPath: string;
7
+ outputDir: string;
7
8
  }
8
9
  export declare function resolveCliOptions(args: string[]): ResolvedCliOptions;
9
10
  export {};
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAM7D,UAAU,kBAAmB,SAAQ,aAAa;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,kBAAkB,CA0BpE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAO7D,UAAU,kBAAmB,SAAQ,aAAa;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,kBAAkB,CA4BpE"}
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startServer
4
- } from "./chunk-TS64TROX.js";
5
- import "./chunk-EJRLZZ22.js";
4
+ } from "./chunk-35CZLYSW.js";
5
+ import "./chunk-43JLQN36.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { join } from "path";
@@ -31,6 +31,7 @@ function isDirectExecution(moduleUrl) {
31
31
  var DEFAULT_PORT = 3e3;
32
32
  var DEFAULT_SCREENSHOT_DIR = "./screenshots";
33
33
  var DEFAULT_REPORT_PATH = "./report.json";
34
+ var DEFAULT_OUTPUT_DIR = "./test-results";
34
35
  function resolveCliOptions(args) {
35
36
  const { values, positionals } = parseArgs({
36
37
  args,
@@ -38,7 +39,8 @@ function resolveCliOptions(args) {
38
39
  options: {
39
40
  port: { type: "string", short: "p", default: `${DEFAULT_PORT}` },
40
41
  "screenshot-dir": { type: "string", short: "s" },
41
- "report-path": { type: "string", short: "r" }
42
+ "report-path": { type: "string", short: "r" },
43
+ "output-dir": { type: "string", short: "o" }
42
44
  }
43
45
  });
44
46
  if (positionals.length > 1) {
@@ -50,7 +52,8 @@ function resolveCliOptions(args) {
50
52
  return {
51
53
  port: parseInt(values.port ?? `${DEFAULT_PORT}`, 10),
52
54
  screenshotDir,
53
- reportPath
55
+ reportPath,
56
+ outputDir: values["output-dir"] ?? DEFAULT_OUTPUT_DIR
54
57
  };
55
58
  }
56
59
  if (isDirectExecution(import.meta.url)) {