@crvy/rprtr 0.1.3 → 0.2.2

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 (45) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +15 -6
  3. package/dist/{chunk-X2TCDD54.js → chunk-HAFWYUNO.js} +204 -137
  4. package/dist/{chunk-F7NSIHCV.js → chunk-RFZGJSL3.js} +448 -128
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +6 -4
  7. package/dist/index.css +29 -0
  8. package/dist/index.js +297 -76
  9. package/dist/offline-reports.d.ts +4 -0
  10. package/dist/offline-reports.d.ts.map +1 -1
  11. package/dist/path-utils.d.ts +3 -0
  12. package/dist/path-utils.d.ts.map +1 -0
  13. package/dist/report-state.d.ts +3 -1
  14. package/dist/report-state.d.ts.map +1 -1
  15. package/dist/report-utils.d.ts.map +1 -1
  16. package/dist/reporter-utils.d.ts +0 -1
  17. package/dist/reporter-utils.d.ts.map +1 -1
  18. package/dist/reporter.cjs +232 -170
  19. package/dist/reporter.d.ts +1 -1
  20. package/dist/reporter.d.ts.map +1 -1
  21. package/dist/reporter.js +8 -6
  22. package/dist/schemas/http.d.ts +44 -0
  23. package/dist/schemas/http.d.ts.map +1 -0
  24. package/dist/schemas.d.ts +25 -6
  25. package/dist/schemas.d.ts.map +1 -1
  26. package/dist/server/app.d.ts +6 -0
  27. package/dist/server/app.d.ts.map +1 -1
  28. package/dist/server/artifact-routes.d.ts.map +1 -1
  29. package/dist/server/handlers.d.ts +3 -0
  30. package/dist/server/handlers.d.ts.map +1 -1
  31. package/dist/server/playwright-config.d.ts +2 -0
  32. package/dist/server/playwright-config.d.ts.map +1 -0
  33. package/dist/server/routes-context.d.ts +13 -0
  34. package/dist/server/routes-context.d.ts.map +1 -0
  35. package/dist/server/routes.d.ts +6 -1
  36. package/dist/server/routes.d.ts.map +1 -1
  37. package/dist/server/run-controller.d.ts +85 -0
  38. package/dist/server/run-controller.d.ts.map +1 -0
  39. package/dist/server/run-routes.d.ts +3 -0
  40. package/dist/server/run-routes.d.ts.map +1 -0
  41. package/dist/server.cjs +668 -286
  42. package/dist/server.js +2 -2
  43. package/dist/types.d.ts +6 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/package.json +6 -5
package/dist/server.cjs CHANGED
@@ -37,14 +37,32 @@ module.exports = __toCommonJS(server_exports);
37
37
  // src/server/app.ts
38
38
  var import_path8 = require("path");
39
39
  var import_url = require("url");
40
- var import_p_limit = __toESM(require("p-limit"), 1);
41
40
 
42
41
  // src/offline-reports.ts
43
- var import_promises = require("fs/promises");
42
+ var import_promises2 = require("fs/promises");
44
43
  var import_path2 = require("path");
44
+ var import_p_limit = __toESM(require("p-limit"), 1);
45
+
46
+ // src/path-utils.ts
47
+ var posixAbsolutePattern = /^\//u;
48
+ var windowsDrivePattern = /^[A-Za-z]:[\\/]/u;
49
+ var windowsUncPattern = /^[\\/][\\/]/u;
50
+ function isPosixAbsolutePath(p) {
51
+ return posixAbsolutePattern.test(p);
52
+ }
53
+ function isWindowsAbsolutePath(p) {
54
+ return windowsDrivePattern.test(p) || windowsUncPattern.test(p);
55
+ }
56
+ function isAnyAbsolutePath(p) {
57
+ return isPosixAbsolutePath(p) || isWindowsAbsolutePath(p);
58
+ }
59
+ function isForeignAbsolutePath(p, hostPlatform) {
60
+ if (!isAnyAbsolutePath(p)) return false;
61
+ if (hostPlatform === "win32") return !isWindowsAbsolutePath(p);
62
+ return !isPosixAbsolutePath(p);
63
+ }
45
64
 
46
65
  // src/report-utils.ts
47
- var import_path = require("path");
48
66
  function normalizeScreenshotsBaseUrl(baseUrl) {
49
67
  return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
50
68
  }
@@ -89,7 +107,7 @@ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/")
89
107
  const role = match[2];
90
108
  if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
91
109
  images[baseName] ??= {};
92
- const url = (0, import_path.isAbsolute)(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
110
+ const url = isAnyAbsolutePath(attachment.path) ? `/file/${encodeURIComponent(attachment.path)}` : `${baseUrl}${attachment.path}`;
93
111
  const img = images[baseName];
94
112
  if (img !== null && img !== void 0) {
95
113
  if (role === "actual") img.actual = url;
@@ -124,9 +142,6 @@ function isCurrentArtifact(image) {
124
142
  }
125
143
  function isReusablePassingImage(image) {
126
144
  const source = image.source ?? classifyImage(image);
127
- if (source === "comparison") {
128
- return image.actual !== void 0 && image.diff === void 0;
129
- }
130
145
  return source === "baseline-only";
131
146
  }
132
147
  function hasReusablePassingImages(images) {
@@ -147,8 +162,8 @@ function preservePreviousPassingImages(test, status, images) {
147
162
  return {
148
163
  ...currentImages,
149
164
  [name]: {
150
- ...previousImage,
151
- source: previousImage.source ?? classifyImage(previousImage)
165
+ expect: previousImage.expect,
166
+ source: "baseline-only"
152
167
  }
153
168
  };
154
169
  }, images);
@@ -171,7 +186,12 @@ function createMutableReportState(screenshotDir = "./screenshots") {
171
186
  function applyTestBeginEvent(state, data) {
172
187
  const { id, title, titlePath, browser, projectName, location } = data;
173
188
  state.currentRunIds.add(id);
174
- state.reportData.tests[id] ??= {
189
+ const existing = state.reportData.tests[id];
190
+ if (existing !== void 0) {
191
+ existing.status = "running";
192
+ return existing;
193
+ }
194
+ const created = {
175
195
  id,
176
196
  titlePath: titlePath ?? [],
177
197
  browser: browser ?? "",
@@ -183,7 +203,8 @@ function applyTestBeginEvent(state, data) {
183
203
  location,
184
204
  status: "running"
185
205
  };
186
- return state.reportData.tests[id];
206
+ state.reportData.tests[id] = created;
207
+ return created;
187
208
  }
188
209
  function applyTestEndEvent(state, data, options = {}) {
189
210
  const test = state.reportData.tests[data.id];
@@ -221,11 +242,13 @@ function applyTestEndEvent(state, data, options = {}) {
221
242
  diffCount
222
243
  };
223
244
  }
224
- function finalizeRunEvent(state) {
245
+ function finalizeRunEvent(state, options = {}) {
225
246
  state.reportData.isRunning = false;
226
- state.reportData.tests = Object.fromEntries(
227
- Object.entries(state.reportData.tests).filter(([id]) => state.currentRunIds.has(id))
228
- );
247
+ if (options.preserveNonCurrent !== true) {
248
+ state.reportData.tests = Object.fromEntries(
249
+ Object.entries(state.reportData.tests).filter(([id]) => state.currentRunIds.has(id))
250
+ );
251
+ }
229
252
  state.currentRunIds.clear();
230
253
  const runTests = Object.values(state.reportData.tests).filter((test) => test !== void 0);
231
254
  return {
@@ -236,169 +259,211 @@ function finalizeRunEvent(state) {
236
259
  }
237
260
 
238
261
  // src/schemas.ts
262
+ var import_zod2 = require("zod");
263
+
264
+ // src/schemas/http.ts
239
265
  var import_zod = require("zod");
240
- var LocationSchema = import_zod.z.object({
266
+ var ApproveRequestBodySchema = import_zod.z.object({
267
+ id: import_zod.z.string(),
268
+ retry: import_zod.z.number(),
269
+ image: import_zod.z.string()
270
+ });
271
+ var RunTestDescriptorSchema = import_zod.z.object({
241
272
  file: import_zod.z.string(),
242
- line: import_zod.z.number()
273
+ line: import_zod.z.number(),
274
+ column: import_zod.z.number().optional(),
275
+ projectName: import_zod.z.string().optional(),
276
+ titlePath: import_zod.z.array(import_zod.z.string())
243
277
  });
244
- var VisualSourceSchema = import_zod.z.enum(["comparison", "baseline-only", "declared-only"]);
245
- var ImagesSchema = import_zod.z.object({
246
- actual: import_zod.z.string().optional(),
247
- expect: import_zod.z.string().optional(),
248
- diff: import_zod.z.string().optional(),
249
- error: import_zod.z.string().optional(),
250
- source: VisualSourceSchema.optional()
278
+ var RunRequestBodySchema = import_zod.z.object({
279
+ tests: import_zod.z.array(RunTestDescriptorSchema).optional()
251
280
  });
252
- var ScreenshotDeclarationSchema = import_zod.z.discriminatedUnion("kind", [
281
+ var RunResponseSchema = import_zod.z.discriminatedUnion("ok", [
282
+ import_zod.z.object({ ok: import_zod.z.literal(true) }),
253
283
  import_zod.z.object({
254
- visualName: import_zod.z.string(),
255
- kind: import_zod.z.literal("named"),
256
- declaredName: import_zod.z.string(),
257
- snapshotBaseName: import_zod.z.string(),
258
- occurrenceIndex: import_zod.z.number()
259
- }),
284
+ ok: import_zod.z.literal(false),
285
+ reason: import_zod.z.enum(["no-config", "already-running", "no-tests"])
286
+ })
287
+ ]);
288
+ var StopResponseSchema = import_zod.z.discriminatedUnion("ok", [
289
+ import_zod.z.object({ ok: import_zod.z.literal(true) }),
260
290
  import_zod.z.object({
261
- visualName: import_zod.z.string(),
262
- kind: import_zod.z.literal("unnamed"),
263
- occurrenceIndex: import_zod.z.number()
291
+ ok: import_zod.z.literal(false),
292
+ reason: import_zod.z.literal("not-running")
293
+ })
294
+ ]);
295
+
296
+ // src/schemas.ts
297
+ var LocationSchema = import_zod2.z.object({
298
+ file: import_zod2.z.string(),
299
+ line: import_zod2.z.number(),
300
+ column: import_zod2.z.number().optional()
301
+ });
302
+ var VisualSourceSchema = import_zod2.z.enum(["comparison", "baseline-only", "declared-only"]);
303
+ var ImagesSchema = import_zod2.z.object({
304
+ actual: import_zod2.z.string().optional(),
305
+ expect: import_zod2.z.string().optional(),
306
+ diff: import_zod2.z.string().optional(),
307
+ error: import_zod2.z.string().optional(),
308
+ source: VisualSourceSchema.optional()
309
+ });
310
+ var ScreenshotDeclarationSchema = import_zod2.z.discriminatedUnion("kind", [
311
+ import_zod2.z.object({
312
+ visualName: import_zod2.z.string(),
313
+ kind: import_zod2.z.literal("named"),
314
+ declaredName: import_zod2.z.string(),
315
+ snapshotBaseName: import_zod2.z.string(),
316
+ occurrenceIndex: import_zod2.z.number()
317
+ }),
318
+ import_zod2.z.object({
319
+ visualName: import_zod2.z.string(),
320
+ kind: import_zod2.z.literal("unnamed"),
321
+ occurrenceIndex: import_zod2.z.number()
264
322
  })
265
323
  ]);
266
- var AttachmentSchema = import_zod.z.object({
267
- name: import_zod.z.string(),
268
- path: import_zod.z.string(),
269
- contentType: import_zod.z.string()
324
+ var AttachmentSchema = import_zod2.z.object({
325
+ name: import_zod2.z.string(),
326
+ path: import_zod2.z.string(),
327
+ contentType: import_zod2.z.string()
270
328
  });
271
- var TestStatusSchema = import_zod.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
272
- var TestResultStatusSchema = import_zod.z.enum(["failed", "success", "pending"]);
273
- var TestResultSchema = import_zod.z.object({
329
+ var TestStatusSchema = import_zod2.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
330
+ var TestResultStatusSchema = import_zod2.z.enum(["failed", "success", "pending"]);
331
+ var TestResultSchema = import_zod2.z.object({
274
332
  status: TestResultStatusSchema,
275
- retries: import_zod.z.number(),
276
- images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
277
- visualDeclarations: import_zod.z.array(ScreenshotDeclarationSchema).optional(),
278
- error: import_zod.z.string().optional(),
279
- duration: import_zod.z.number().optional()
333
+ retries: import_zod2.z.number(),
334
+ images: import_zod2.z.record(import_zod2.z.string(), ImagesSchema).optional(),
335
+ visualDeclarations: import_zod2.z.array(ScreenshotDeclarationSchema).optional(),
336
+ error: import_zod2.z.string().optional(),
337
+ duration: import_zod2.z.number().optional()
280
338
  });
281
- var TestDataSchema = import_zod.z.object({
282
- id: import_zod.z.string(),
283
- titlePath: import_zod.z.array(import_zod.z.string()),
284
- browser: import_zod.z.string(),
285
- projectName: import_zod.z.string().optional(),
286
- title: import_zod.z.string(),
287
- skip: import_zod.z.union([import_zod.z.boolean(), import_zod.z.string()]).optional(),
288
- retries: import_zod.z.number().optional(),
339
+ var TestDataSchema = import_zod2.z.object({
340
+ id: import_zod2.z.string(),
341
+ titlePath: import_zod2.z.array(import_zod2.z.string()),
342
+ browser: import_zod2.z.string(),
343
+ projectName: import_zod2.z.string().optional(),
344
+ title: import_zod2.z.string(),
345
+ skip: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.string()]).optional(),
346
+ retries: import_zod2.z.number().optional(),
289
347
  status: TestStatusSchema.optional(),
290
- results: import_zod.z.array(TestResultSchema).optional(),
291
- approved: import_zod.z.record(import_zod.z.string(), import_zod.z.number()).nullable().optional(),
292
- attachments: import_zod.z.array(AttachmentSchema).optional(),
348
+ results: import_zod2.z.array(TestResultSchema).optional(),
349
+ approved: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.number()).nullable().optional(),
350
+ attachments: import_zod2.z.array(AttachmentSchema).optional(),
293
351
  location: LocationSchema.optional()
294
352
  });
295
353
  var CrvyRprtrTestSchema = TestDataSchema.extend({
296
- checked: import_zod.z.boolean()
354
+ checked: import_zod2.z.boolean()
297
355
  });
298
- var CrvyRprtrSuiteSchema = import_zod.z.lazy(
299
- () => import_zod.z.object({
300
- path: import_zod.z.array(import_zod.z.string()),
301
- skip: import_zod.z.boolean(),
356
+ var CrvyRprtrSuiteSchema = import_zod2.z.lazy(
357
+ () => import_zod2.z.object({
358
+ path: import_zod2.z.array(import_zod2.z.string()),
359
+ skip: import_zod2.z.boolean(),
302
360
  status: TestStatusSchema.optional(),
303
- opened: import_zod.z.boolean(),
304
- checked: import_zod.z.boolean(),
305
- indeterminate: import_zod.z.boolean(),
306
- children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
361
+ opened: import_zod2.z.boolean(),
362
+ checked: import_zod2.z.boolean(),
363
+ indeterminate: import_zod2.z.boolean(),
364
+ children: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
307
365
  })
308
366
  );
309
- var IncomingWebSocketMessageSchema = import_zod.z.object({
310
- type: import_zod.z.enum(["test-begin", "test-end", "run-end", "approve", "sync", "register"]),
311
- data: import_zod.z.unknown()
367
+ var IncomingWebSocketMessageSchema = import_zod2.z.object({
368
+ type: import_zod2.z.enum(["test-begin", "test-end", "run-end", "approve", "sync", "register"]),
369
+ data: import_zod2.z.unknown()
312
370
  });
313
- var WebSocketMessageSchema = import_zod.z.discriminatedUnion("type", [
314
- import_zod.z.object({ type: import_zod.z.literal("test-begin"), data: TestDataSchema }),
315
- import_zod.z.object({ type: import_zod.z.literal("test-update"), data: TestDataSchema }),
316
- import_zod.z.object({
317
- type: import_zod.z.literal("run-end"),
318
- data: import_zod.z.object({
319
- status: import_zod.z.enum(["passed", "failed", "skipped"]),
320
- removedTestIds: import_zod.z.array(import_zod.z.string())
371
+ var WebSocketMessageSchema = import_zod2.z.discriminatedUnion("type", [
372
+ import_zod2.z.object({ type: import_zod2.z.literal("test-begin"), data: TestDataSchema }),
373
+ import_zod2.z.object({ type: import_zod2.z.literal("test-update"), data: TestDataSchema }),
374
+ import_zod2.z.object({
375
+ type: import_zod2.z.literal("run-end"),
376
+ data: import_zod2.z.object({
377
+ status: import_zod2.z.enum(["passed", "failed", "skipped"]),
378
+ removedTestIds: import_zod2.z.array(import_zod2.z.string())
321
379
  })
322
380
  }),
323
- import_zod.z.object({
324
- type: import_zod.z.literal("sync"),
325
- data: import_zod.z.object({
326
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
327
- isUpdateMode: import_zod.z.boolean().optional()
381
+ import_zod2.z.object({
382
+ type: import_zod2.z.literal("sync"),
383
+ data: import_zod2.z.object({
384
+ tests: import_zod2.z.record(import_zod2.z.string(), TestDataSchema),
385
+ isUpdateMode: import_zod2.z.boolean().optional()
328
386
  })
329
387
  }),
330
- import_zod.z.object({ type: import_zod.z.literal("approve"), data: import_zod.z.unknown() })
388
+ import_zod2.z.object({ type: import_zod2.z.literal("approve"), data: import_zod2.z.unknown() }),
389
+ import_zod2.z.object({
390
+ type: import_zod2.z.literal("run-status"),
391
+ data: import_zod2.z.object({
392
+ running: import_zod2.z.boolean()
393
+ })
394
+ })
331
395
  ]);
332
- var TestBeginDataSchema = import_zod.z.object({
333
- id: import_zod.z.string(),
334
- title: import_zod.z.string(),
335
- titlePath: import_zod.z.array(import_zod.z.string()),
336
- browser: import_zod.z.string(),
337
- projectName: import_zod.z.string().optional(),
396
+ var TestBeginDataSchema = import_zod2.z.object({
397
+ id: import_zod2.z.string(),
398
+ title: import_zod2.z.string(),
399
+ titlePath: import_zod2.z.array(import_zod2.z.string()),
400
+ browser: import_zod2.z.string(),
401
+ projectName: import_zod2.z.string().optional(),
338
402
  location: LocationSchema
339
403
  });
340
- var TestEndDataSchema = import_zod.z.object({
341
- id: import_zod.z.string(),
342
- status: import_zod.z.enum(["passed", "failed", "skipped"]),
343
- attachments: import_zod.z.array(AttachmentSchema),
344
- visualNames: import_zod.z.array(import_zod.z.string()).default([]),
345
- visualDeclarations: import_zod.z.preprocess(
404
+ var TestEndDataSchema = import_zod2.z.object({
405
+ id: import_zod2.z.string(),
406
+ status: import_zod2.z.enum(["passed", "failed", "skipped"]),
407
+ attachments: import_zod2.z.array(AttachmentSchema),
408
+ visualNames: import_zod2.z.array(import_zod2.z.string()).default([]),
409
+ visualDeclarations: import_zod2.z.preprocess(
346
410
  (value) => value === null ? void 0 : value,
347
- import_zod.z.array(ScreenshotDeclarationSchema).optional()
411
+ import_zod2.z.array(ScreenshotDeclarationSchema).optional()
348
412
  ),
349
- error: import_zod.z.string().optional(),
350
- duration: import_zod.z.number().optional()
351
- });
352
- var RunEndDataSchema = import_zod.z.object({
353
- status: import_zod.z.enum(["passed", "failed", "skipped"])
413
+ error: import_zod2.z.string().optional(),
414
+ duration: import_zod2.z.number().optional()
354
415
  });
355
- var RegisterDataSchema = import_zod.z.object({
356
- playwrightSnapshotDir: import_zod.z.string().optional(),
357
- playwrightTestDir: import_zod.z.string().optional(),
358
- playwrightSnapshotPathTemplate: import_zod.z.string().optional(),
359
- playwrightToHaveScreenshotPathTemplate: import_zod.z.string().optional()
416
+ var RunEndDataSchema = import_zod2.z.object({
417
+ status: import_zod2.z.enum(["passed", "failed", "skipped"])
360
418
  });
361
- var ReportDataSchema = import_zod.z.object({
362
- isRunning: import_zod.z.boolean(),
363
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
364
- browsers: import_zod.z.array(import_zod.z.string()),
365
- isUpdateMode: import_zod.z.boolean(),
366
- screenshotDir: import_zod.z.string()
419
+ var RegisterDataSchema = import_zod2.z.object({
420
+ playwrightSnapshotDir: import_zod2.z.string().optional(),
421
+ playwrightTestDir: import_zod2.z.string().optional(),
422
+ playwrightSnapshotPathTemplate: import_zod2.z.string().optional(),
423
+ playwrightToHaveScreenshotPathTemplate: import_zod2.z.string().optional(),
424
+ configFile: import_zod2.z.string().optional(),
425
+ cwd: import_zod2.z.string().optional()
367
426
  });
368
- var LoadedReportDataSchema = import_zod.z.object({
369
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema).optional(),
370
- isUpdateMode: import_zod.z.boolean().optional()
427
+ var ReportDataSchema = import_zod2.z.object({
428
+ isRunning: import_zod2.z.boolean(),
429
+ tests: import_zod2.z.record(import_zod2.z.string(), TestDataSchema),
430
+ browsers: import_zod2.z.array(import_zod2.z.string()),
431
+ isUpdateMode: import_zod2.z.boolean(),
432
+ screenshotDir: import_zod2.z.string()
371
433
  });
372
- var OfflineEventSchema = import_zod.z.object({
373
- type: import_zod.z.enum(["test-begin", "test-end", "run-end"]),
374
- data: import_zod.z.unknown(),
375
- timestamp: import_zod.z.number(),
376
- workerIndex: import_zod.z.number()
434
+ var LoadedReportDataSchema = import_zod2.z.object({
435
+ tests: import_zod2.z.record(import_zod2.z.string(), TestDataSchema).optional(),
436
+ isUpdateMode: import_zod2.z.boolean().optional()
377
437
  });
378
- var OfflineReportSchema = import_zod.z.object({
379
- version: import_zod.z.number(),
380
- generatedAt: import_zod.z.string(),
381
- workers: import_zod.z.number(),
382
- events: import_zod.z.array(OfflineEventSchema)
438
+ var OfflineEventSchema = import_zod2.z.object({
439
+ type: import_zod2.z.enum(["test-begin", "test-end", "run-end"]),
440
+ data: import_zod2.z.unknown(),
441
+ timestamp: import_zod2.z.number(),
442
+ workerIndex: import_zod2.z.number()
383
443
  });
384
- var ApproveRequestBodySchema = import_zod.z.object({
385
- id: import_zod.z.string(),
386
- retry: import_zod.z.number(),
387
- image: import_zod.z.string()
444
+ var OfflineReportSchema = import_zod2.z.object({
445
+ version: import_zod2.z.number(),
446
+ generatedAt: import_zod2.z.string(),
447
+ workers: import_zod2.z.number(),
448
+ events: import_zod2.z.array(OfflineEventSchema)
388
449
  });
389
- var ReportApiResponseSchema = import_zod.z.object({
390
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
391
- isUpdateMode: import_zod.z.boolean().optional()
450
+ var ReportApiResponseSchema = import_zod2.z.object({
451
+ tests: import_zod2.z.record(import_zod2.z.string(), TestDataSchema),
452
+ isUpdateMode: import_zod2.z.boolean().optional(),
453
+ isRunning: import_zod2.z.boolean().optional(),
454
+ runEnabled: import_zod2.z.boolean().optional()
392
455
  });
393
- var ClientBootstrapDataSchema = import_zod.z.object({
456
+ var ClientBootstrapDataSchema = import_zod2.z.object({
394
457
  report: ReportApiResponseSchema.extend({
395
- isUpdateMode: import_zod.z.boolean()
458
+ isUpdateMode: import_zod2.z.boolean()
396
459
  }),
397
- liveUpdates: import_zod.z.boolean(),
398
- approvalEnabled: import_zod.z.boolean(),
399
- approvalMessage: import_zod.z.string().optional()
460
+ liveUpdates: import_zod2.z.boolean(),
461
+ approvalEnabled: import_zod2.z.boolean(),
462
+ approvalMessage: import_zod2.z.string().optional(),
463
+ runEnabled: import_zod2.z.boolean().optional(),
464
+ isRunning: import_zod2.z.boolean().optional()
400
465
  });
401
- var ImagesViewModeSchema = import_zod.z.enum(["side-by-side", "swap", "slide", "blend"]);
466
+ var ImagesViewModeSchema = import_zod2.z.enum(["side-by-side", "swap", "slide", "blend"]);
402
467
  function safeParse(schema, data) {
403
468
  const result = schema.safeParse(data);
404
469
  if (result.success) {
@@ -407,70 +472,15 @@ function safeParse(schema, data) {
407
472
  return null;
408
473
  }
409
474
 
410
- // src/offline-reports.ts
411
- var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
412
- async function findOfflineReportPaths(searchDir) {
413
- try {
414
- const entries = await (0, import_promises.readdir)(searchDir);
415
- return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path2.join)(searchDir, entry));
416
- } catch (error) {
417
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
418
- return [];
419
- }
420
- throw error;
421
- }
422
- }
423
- function parseOfflineReport(value) {
424
- const parsed = safeParse(OfflineReportSchema, value);
425
- if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
426
- return null;
427
- }
428
- return parsed;
429
- }
430
- function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
431
- const state = createMutableReportState(options.screenshotDir);
432
- let shouldFinalize = false;
433
- for (const report of offlineReports) {
434
- for (const event of report.events) {
435
- switch (event.type) {
436
- case "test-begin": {
437
- const parsed = safeParse(TestBeginDataSchema, event.data);
438
- if (parsed !== null) {
439
- applyTestBeginEvent(state, parsed);
440
- }
441
- break;
442
- }
443
- case "test-end": {
444
- const parsed = safeParse(TestEndDataSchema, event.data);
445
- if (parsed !== null) {
446
- applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
447
- }
448
- break;
449
- }
450
- case "run-end":
451
- shouldFinalize = true;
452
- break;
453
- }
454
- }
455
- }
456
- if (shouldFinalize) {
457
- finalizeRunEvent(state);
458
- }
459
- return {
460
- ...existingTests,
461
- ...state.reportData.tests
462
- };
463
- }
464
-
465
475
  // src/server/file-utils.ts
466
- var import_promises2 = require("fs/promises");
467
- var import_path3 = require("path");
476
+ var import_promises = require("fs/promises");
477
+ var import_path = require("path");
468
478
  function isFileNotFound(error) {
469
479
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
470
480
  }
471
481
  async function fileExists(filePath) {
472
482
  try {
473
- await (0, import_promises2.access)(filePath);
483
+ await (0, import_promises.access)(filePath);
474
484
  return true;
475
485
  } catch (error) {
476
486
  if (isFileNotFound(error)) {
@@ -481,7 +491,7 @@ async function fileExists(filePath) {
481
491
  }
482
492
  async function isDirectory(filePath) {
483
493
  try {
484
- const stats = await (0, import_promises2.stat)(filePath);
494
+ const stats = await (0, import_promises.stat)(filePath);
485
495
  return stats.isDirectory();
486
496
  } catch (error) {
487
497
  if (isFileNotFound(error)) {
@@ -492,7 +502,7 @@ async function isDirectory(filePath) {
492
502
  }
493
503
  async function readJsonFile(filePath) {
494
504
  try {
495
- const raw = await (0, import_promises2.readFile)(filePath, "utf8");
505
+ const raw = await (0, import_promises.readFile)(filePath, "utf8");
496
506
  if (raw.trim() === "") {
497
507
  return null;
498
508
  }
@@ -505,16 +515,16 @@ async function readJsonFile(filePath) {
505
515
  }
506
516
  }
507
517
  async function writeJsonFile(filePath, value) {
508
- await (0, import_promises2.mkdir)((0, import_path3.dirname)(filePath), { recursive: true });
509
- await (0, import_promises2.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
518
+ await (0, import_promises.mkdir)((0, import_path.dirname)(filePath), { recursive: true });
519
+ await (0, import_promises.writeFile)(filePath, `${JSON.stringify(value, null, 2)}
510
520
  `);
511
521
  }
512
522
  async function copyFilePortable(sourcePath, destinationPath) {
513
- await (0, import_promises2.mkdir)((0, import_path3.dirname)(destinationPath), { recursive: true });
514
- await (0, import_promises2.copyFile)(sourcePath, destinationPath);
523
+ await (0, import_promises.mkdir)((0, import_path.dirname)(destinationPath), { recursive: true });
524
+ await (0, import_promises.copyFile)(sourcePath, destinationPath);
515
525
  }
516
526
  function inferContentType(filePath) {
517
- switch ((0, import_path3.extname)(filePath).toLowerCase()) {
527
+ switch ((0, import_path.extname)(filePath).toLowerCase()) {
518
528
  case ".css":
519
529
  return "text/css";
520
530
  case ".gif":
@@ -542,7 +552,7 @@ function inferContentType(filePath) {
542
552
  }
543
553
  async function respondWithFile(filePath, contentType) {
544
554
  try {
545
- const file = await (0, import_promises2.readFile)(filePath);
555
+ const file = await (0, import_promises.readFile)(filePath);
546
556
  const resolvedContentType = contentType ?? inferContentType(filePath);
547
557
  const headers = { "X-Content-Type-Options": "nosniff" };
548
558
  if (resolvedContentType !== void 0) {
@@ -557,17 +567,104 @@ async function respondWithFile(filePath, contentType) {
557
567
  }
558
568
  }
559
569
 
570
+ // src/offline-reports.ts
571
+ var MAX_CONCURRENT_FILE_OPS = 5;
572
+ var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
573
+ async function findOfflineReportPaths(searchDir) {
574
+ try {
575
+ const entries = await (0, import_promises2.readdir)(searchDir);
576
+ return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path2.join)(searchDir, entry));
577
+ } catch (error) {
578
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
579
+ return [];
580
+ }
581
+ throw error;
582
+ }
583
+ }
584
+ function parseOfflineReport(value) {
585
+ const parsed = safeParse(OfflineReportSchema, value);
586
+ if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
587
+ return null;
588
+ }
589
+ return parsed;
590
+ }
591
+ async function readOfflineReport(filePath) {
592
+ try {
593
+ const raw = await readJsonFile(filePath);
594
+ if (raw === null) {
595
+ return null;
596
+ }
597
+ const parsed = parseOfflineReport(raw);
598
+ if (parsed !== null) {
599
+ console.log(`[Server] Loading offline report: ${filePath}`);
600
+ return parsed;
601
+ }
602
+ } catch {
603
+ }
604
+ return null;
605
+ }
606
+ async function loadOfflineReports(reportData, offlineReportDir) {
607
+ const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
608
+ if (offlineReportPaths.length === 0) {
609
+ return;
610
+ }
611
+ const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
612
+ const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
613
+ const validReports = reports.filter((report) => report !== null);
614
+ if (validReports.length === 0) {
615
+ return;
616
+ }
617
+ reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
618
+ screenshotDir: reportData.screenshotDir,
619
+ screenshotsBaseUrl: "/screenshots/"
620
+ });
621
+ }
622
+ function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
623
+ const state = createMutableReportState(options.screenshotDir);
624
+ let shouldFinalize = false;
625
+ for (const report of offlineReports) {
626
+ for (const event of report.events) {
627
+ switch (event.type) {
628
+ case "test-begin": {
629
+ const parsed = safeParse(TestBeginDataSchema, event.data);
630
+ if (parsed !== null) {
631
+ applyTestBeginEvent(state, parsed);
632
+ }
633
+ break;
634
+ }
635
+ case "test-end": {
636
+ const parsed = safeParse(TestEndDataSchema, event.data);
637
+ if (parsed !== null) {
638
+ applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
639
+ }
640
+ break;
641
+ }
642
+ case "run-end":
643
+ shouldFinalize = true;
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ if (shouldFinalize) {
649
+ finalizeRunEvent(state);
650
+ }
651
+ return {
652
+ ...existingTests,
653
+ ...state.reportData.tests
654
+ };
655
+ }
656
+
560
657
  // src/server/handlers.ts
561
658
  var import_fs2 = require("fs");
562
659
 
563
660
  // src/server/artifact-routes.ts
564
661
  var import_fs = require("fs");
565
662
  var import_promises3 = require("fs/promises");
566
- var import_path6 = require("path");
663
+ var import_path5 = require("path");
567
664
 
568
665
  // src/snapshot-path-resolver.ts
569
666
  var import_crypto = require("crypto");
570
- var import_path4 = require("path");
667
+ var import_path3 = require("path");
571
668
  var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
572
669
  var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
573
670
  function isUnsafeFilePathCharacter(character) {
@@ -602,16 +699,16 @@ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
602
699
  const end = length - middle.length - start;
603
700
  return value.slice(0, start) + middle + value.slice(-end);
604
701
  }
605
- function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
702
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
606
703
  const base = filePath.slice(0, filePath.length - extension.length);
607
704
  return sanitizeForFilePath(base) + extension;
608
705
  }
609
706
  function addSuffixToFilePath(filePath, suffix) {
610
- const extension = (0, import_path4.extname)(filePath);
707
+ const extension = (0, import_path3.extname)(filePath);
611
708
  return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
612
709
  }
613
710
  function normalizedSnapshotDir(config) {
614
- return (0, import_path4.resolve)(config.configDir, config.snapshotDir);
711
+ return (0, import_path3.resolve)(config.configDir, config.snapshotDir);
615
712
  }
616
713
  function templateValue(template, token, value) {
617
714
  return template.replace(
@@ -621,8 +718,8 @@ function templateValue(template, token, value) {
621
718
  }
622
719
  function applyTemplate(input, nameArgument, extension) {
623
720
  const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
624
- const relativeTestFilePath = (0, import_path4.relative)(input.config.testDir, input.testFile);
625
- const parsed = (0, import_path4.parse)(relativeTestFilePath);
721
+ const relativeTestFilePath = (0, import_path3.relative)(input.config.testDir, input.testFile);
722
+ const parsed = (0, import_path3.parse)(relativeTestFilePath);
626
723
  const tokens = [
627
724
  ["testDir", input.config.testDir],
628
725
  ["snapshotDir", normalizedSnapshotDir(input.config)],
@@ -640,16 +737,16 @@ function applyTemplate(input, nameArgument, extension) {
640
737
  (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
641
738
  template
642
739
  );
643
- return (0, import_path4.resolve)(input.config.configDir, snapshotPath);
740
+ return (0, import_path3.resolve)(input.config.configDir, snapshotPath);
644
741
  }
645
- function removeExtension(filePath, extension = (0, import_path4.extname)(filePath)) {
742
+ function removeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
646
743
  return filePath.slice(0, filePath.length - extension.length);
647
744
  }
648
745
  function snapshotNameParts(declaredName) {
649
- const extension = (0, import_path4.extname)(declaredName) || ".png";
746
+ const extension = (0, import_path3.extname)(declaredName) || ".png";
650
747
  return {
651
748
  extension,
652
- filePath: (0, import_path4.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
749
+ filePath: (0, import_path3.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
653
750
  };
654
751
  }
655
752
  function filePathForOccurrence(filePath, occurrenceIndex) {
@@ -673,7 +770,7 @@ function resolveStringCallTarget(input, declaration) {
673
770
  function resolveArrayCallTarget(input, declaration) {
674
771
  const { extension, filePath } = snapshotNameParts(declaration.declaredName);
675
772
  const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
676
- const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(occurrenceFilePath), (0, import_path4.basename)(occurrenceFilePath, extension));
773
+ const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(occurrenceFilePath), (0, import_path3.basename)(occurrenceFilePath, extension));
677
774
  return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
678
775
  }
679
776
  function resolveNamedTarget(input, declaration) {
@@ -711,7 +808,7 @@ function resolveTarget(input, declaration) {
711
808
  case "unnamed": {
712
809
  const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
713
810
  const extension = ".png";
714
- const nameArgument = (0, import_path4.join)((0, import_path4.dirname)(anonymousFileName), (0, import_path4.basename)(anonymousFileName, extension));
811
+ const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(anonymousFileName), (0, import_path3.basename)(anonymousFileName, extension));
715
812
  return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
716
813
  }
717
814
  }
@@ -724,7 +821,7 @@ function resolveBaselineTargets(input) {
724
821
  }
725
822
 
726
823
  // src/server/utils.ts
727
- var import_path5 = require("path");
824
+ var import_path4 = require("path");
728
825
  var LIVE_UPDATES_WEBSOCKET_PATH = "/";
729
826
  function broadcastToBrowsers(wsClients, msg) {
730
827
  const payload = JSON.stringify(msg);
@@ -736,10 +833,10 @@ function isWebSocketUpgradeRequest(req) {
736
833
  return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
737
834
  }
738
835
  function isPathWithinRoots(target, roots) {
739
- const resolvedTarget = (0, import_path5.resolve)(target);
836
+ const resolvedTarget = (0, import_path4.resolve)(target);
740
837
  return roots.some((root) => {
741
- const rel = (0, import_path5.relative)((0, import_path5.resolve)(root), resolvedTarget);
742
- return rel === "" || !rel.startsWith(`..${import_path5.sep}`) && rel !== ".." && !(0, import_path5.isAbsolute)(rel);
838
+ const rel = (0, import_path4.relative)((0, import_path4.resolve)(root), resolvedTarget);
839
+ return rel === "" || !rel.startsWith(`..${import_path4.sep}`) && rel !== ".." && !(0, import_path4.isAbsolute)(rel);
743
840
  });
744
841
  }
745
842
 
@@ -753,17 +850,24 @@ async function realpathOrNull(path) {
753
850
  }
754
851
  async function handleFile(ctx, req) {
755
852
  const notFound = () => new Response("Not Found", { status: 404 });
853
+ let rawDecoded;
756
854
  let decodedPath;
757
855
  try {
758
- decodedPath = (0, import_path6.resolve)(decodeURIComponent(new URL(req.url).pathname.slice("/file/".length)));
856
+ rawDecoded = decodeURIComponent(new URL(req.url).pathname.slice("/file/".length));
857
+ decodedPath = (0, import_path5.resolve)(rawDecoded);
759
858
  } catch {
760
859
  return notFound();
761
860
  }
762
861
  const realTarget = await realpathOrNull(decodedPath);
763
862
  if (realTarget === null) {
863
+ if (isForeignAbsolutePath(rawDecoded, process.platform)) {
864
+ console.warn(
865
+ `[Crvy Rprtr] /file request resolved to a foreign-OS absolute path that cannot be served on ${process.platform}: ${rawDecoded}`
866
+ );
867
+ }
764
868
  return notFound();
765
869
  }
766
- const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull((0, import_path6.resolve)(root))))).filter(
870
+ const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull((0, import_path5.resolve)(root))))).filter(
767
871
  (root) => root !== null
768
872
  );
769
873
  if (!isPathWithinRoots(realTarget, realRoots)) {
@@ -793,8 +897,8 @@ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
793
897
  declarations: [declaration],
794
898
  config: {
795
899
  configDir: routing.configDir,
796
- testDir: routing.playwrightTestDir ?? (0, import_path6.dirname)(testFile),
797
- snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path6.dirname)(testFile),
900
+ testDir: routing.playwrightTestDir ?? (0, import_path5.dirname)(testFile),
901
+ snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path5.dirname)(testFile),
798
902
  projectName: test.projectName ?? test.browser,
799
903
  snapshotSuffix: process.platform,
800
904
  snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
@@ -892,8 +996,8 @@ function handleTestEnd(ctx, data) {
892
996
  broadcastToBrowsers(ctx.wsClients, message);
893
997
  }
894
998
  async function handleRunEnd(ctx, data) {
895
- const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
896
- const { passed, failed, pending } = finalizeRunEvent(ctx);
999
+ const removedTestIds = ctx.isFilteredRun ? [] : Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
1000
+ const { passed, failed, pending } = finalizeRunEvent(ctx, { preserveNonCurrent: ctx.isFilteredRun });
897
1001
  await ctx.saveReport();
898
1002
  console.log(`
899
1003
  Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
@@ -946,14 +1050,91 @@ function handleRegister(ctx, data) {
946
1050
  ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
947
1051
  }
948
1052
  }
1053
+ if (data.configFile !== void 0 && data.cwd !== void 0) {
1054
+ ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
1055
+ }
949
1056
  console.log("[Server] Reporter registered with config:", {
950
1057
  playwrightSnapshotDir: data.playwrightSnapshotDir,
951
1058
  playwrightTestDir: data.playwrightTestDir
952
1059
  });
953
1060
  }
954
1061
 
1062
+ // src/server/playwright-config.ts
1063
+ var import_path6 = require("path");
1064
+ var CONFIG_FILES = [
1065
+ "playwright.config.ts",
1066
+ "playwright.config.mts",
1067
+ "playwright.config.cts",
1068
+ "playwright.config.js",
1069
+ "playwright.config.mjs",
1070
+ "playwright.config.cjs"
1071
+ ];
1072
+ async function resolvePlaywrightConfig(cwd) {
1073
+ const matches = await Promise.all(
1074
+ CONFIG_FILES.map(async (file) => {
1075
+ const candidate = (0, import_path6.join)(cwd, file);
1076
+ return await fileExists(candidate) ? candidate : null;
1077
+ })
1078
+ );
1079
+ return matches.find((path) => path !== null) ?? null;
1080
+ }
1081
+
1082
+ // src/server/routes-context.ts
1083
+ function createRoutesContext(reportData, staticDir, saveReport, options) {
1084
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1085
+ (root) => root !== void 0 && root !== ""
1086
+ );
1087
+ return {
1088
+ reportData,
1089
+ staticDir,
1090
+ saveReport,
1091
+ artifactRoots,
1092
+ approvalRouting: {
1093
+ configDir: options.configDir ?? process.cwd(),
1094
+ playwrightTestDir: options.playwrightTestDir,
1095
+ playwrightSnapshotDir: options.playwrightSnapshotDir,
1096
+ playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1097
+ playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1098
+ },
1099
+ runContext: void 0
1100
+ };
1101
+ }
1102
+
955
1103
  // src/server/routes.ts
956
1104
  var import_path7 = require("path");
1105
+
1106
+ // src/server/run-routes.ts
1107
+ function handleRunRoutes(pathname, method, runController, req) {
1108
+ if (pathname === "/api/run" && method === "POST") {
1109
+ return handleApiRun(runController, req);
1110
+ }
1111
+ if (pathname === "/api/stop" && method === "POST") {
1112
+ return Promise.resolve(handleApiStop(runController));
1113
+ }
1114
+ return null;
1115
+ }
1116
+ async function handleApiRun(runController, req) {
1117
+ let body = {};
1118
+ try {
1119
+ body = await req.json();
1120
+ } catch {
1121
+ }
1122
+ const parsed = safeParse(RunRequestBodySchema, body);
1123
+ if (parsed === null) {
1124
+ return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
1125
+ }
1126
+ const result = runController.start(parsed);
1127
+ if (result.ok) return Response.json(result);
1128
+ const status = result.reason === "no-tests" ? 400 : 409;
1129
+ return Response.json(result, { status });
1130
+ }
1131
+ function handleApiStop(runController) {
1132
+ const result = runController.stop();
1133
+ if (result.ok) return Response.json(result);
1134
+ return Response.json(result, { status: 409 });
1135
+ }
1136
+
1137
+ // src/server/routes.ts
957
1138
  async function handleRoot(ctx) {
958
1139
  const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
959
1140
  return html ?? new Response("Not Found", { status: 404 });
@@ -970,7 +1151,10 @@ async function handleSrcFiles(req) {
970
1151
  return file ?? new Response("Not Found", { status: 404 });
971
1152
  }
972
1153
  function handleApiReport(ctx) {
973
- return Response.json(ctx.reportData);
1154
+ return Response.json({
1155
+ ...ctx.reportData,
1156
+ runEnabled: ctx.runContext !== void 0
1157
+ });
974
1158
  }
975
1159
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
976
1160
  function actualPathFromUrl(ctx, actualUrl) {
@@ -1102,7 +1286,7 @@ async function handleDist(ctx, req) {
1102
1286
  const file = await respondWithFile(filePath, contentType);
1103
1287
  return file ?? new Response("Not Found", { status: 404 });
1104
1288
  }
1105
- function handleHttpRequest(ctx, req) {
1289
+ function handleHttpRequest(ctx, req, runController) {
1106
1290
  const pathname = new URL(req.url).pathname;
1107
1291
  if (pathname === "/") {
1108
1292
  return handleRoot(ctx);
@@ -1122,6 +1306,10 @@ function handleHttpRequest(ctx, req) {
1122
1306
  if (pathname === "/api/approve-all" && req.method === "POST") {
1123
1307
  return handleApiApproveAll(ctx);
1124
1308
  }
1309
+ const runResponse = handleRunRoutes(pathname, req.method, runController, req);
1310
+ if (runResponse !== null) {
1311
+ return runResponse;
1312
+ }
1125
1313
  if (pathname.startsWith("/api/images/")) {
1126
1314
  return handleApiImages(req);
1127
1315
  }
@@ -1138,9 +1326,222 @@ function handleHttpRequest(ctx, req) {
1138
1326
  return Promise.resolve(new Response("Not Found", { status: 404 }));
1139
1327
  }
1140
1328
 
1141
- // src/server/app.ts
1329
+ // src/server/run-controller.ts
1330
+ var import_child_process = require("child_process");
1331
+ var import_node_fs = require("node:fs");
1332
+ var import_node_module = require("node:module");
1333
+ var import_node_os = require("node:os");
1334
+ var import_node_path = require("node:path");
1335
+ var import_commands = require("package-manager-detector/commands");
1336
+ var import_detect = require("package-manager-detector/detect");
1142
1337
  var import_meta = { url: require("url").pathToFileURL(__filename).href };
1143
- var MAX_CONCURRENT_FILE_OPS = 5;
1338
+ var STOP_GRACE_MS = 5e3;
1339
+ var KNOWN_SIGNALS = {
1340
+ SIGTERM: "SIGTERM",
1341
+ SIGKILL: "SIGKILL"
1342
+ };
1343
+ function resolvePlaywrightLaunch(cwd, playwrightArgs) {
1344
+ const agent = (0, import_detect.getUserAgent)();
1345
+ const resolved = agent === null ? null : (0, import_commands.resolveCommand)(agent, "execute-local", ["playwright", ...playwrightArgs]);
1346
+ if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
1347
+ return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
1348
+ }
1349
+ function descriptorLocation(d) {
1350
+ return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
1351
+ }
1352
+ function sharedProject(tests) {
1353
+ const names = new Set(tests.map((t) => t.projectName ?? ""));
1354
+ if (names.size === 1) {
1355
+ const name = [...names][0];
1356
+ return name === "" ? void 0 : name;
1357
+ }
1358
+ return void 0;
1359
+ }
1360
+ function gteMinor(version, major, minor) {
1361
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
1362
+ if (match === null) return false;
1363
+ const maj = parseInt(match[1], 10);
1364
+ const min = parseInt(match[2], 10);
1365
+ if (maj !== major) return maj > major;
1366
+ return min >= minor;
1367
+ }
1368
+ function resolvePlaywrightVersion(cwd) {
1369
+ try {
1370
+ const req = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json"));
1371
+ const pkgPath = req.resolve("@playwright/test/package.json");
1372
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)(pkgPath, "utf8"));
1373
+ return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
1374
+ } catch {
1375
+ return null;
1376
+ }
1377
+ }
1378
+ function buildTestListEntries(tests) {
1379
+ return tests.map((d) => {
1380
+ const loc = descriptorLocation(d);
1381
+ const title = d.titlePath.join(" \u203A ");
1382
+ const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
1383
+ return `${prefix}${loc} \u203A ${title}`;
1384
+ });
1385
+ }
1386
+ function defaultWriteTempFile(content) {
1387
+ const path = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
1388
+ (0, import_node_fs.writeFileSync)(path, content, "utf8");
1389
+ return path;
1390
+ }
1391
+ function defaultDeleteTempFile(path) {
1392
+ try {
1393
+ (0, import_node_fs.unlinkSync)(path);
1394
+ } catch {
1395
+ }
1396
+ }
1397
+ function resolveReporterDefault(cwd) {
1398
+ try {
1399
+ return (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json")).resolve("@crvy/rprtr");
1400
+ } catch {
1401
+ }
1402
+ try {
1403
+ return (0, import_node_module.createRequire)(import_meta.url).resolve("@crvy/rprtr");
1404
+ } catch {
1405
+ return null;
1406
+ }
1407
+ }
1408
+ function buildSpawnEnv(port) {
1409
+ const env = {};
1410
+ for (const [key, value] of Object.entries(process.env)) {
1411
+ if (key === "CI") continue;
1412
+ env[key] = value;
1413
+ }
1414
+ env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
1415
+ env.PLAYWRIGHT_HTML_OPEN = "never";
1416
+ return env;
1417
+ }
1418
+ var RunController = class {
1419
+ constructor(deps) {
1420
+ this.deps = deps;
1421
+ }
1422
+ child = null;
1423
+ sigkillTimer = null;
1424
+ testListPath = null;
1425
+ get isRunning() {
1426
+ return this.child !== null;
1427
+ }
1428
+ supportsTestList(cwd) {
1429
+ const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
1430
+ const version = getVersion(cwd);
1431
+ return version !== null && gteMinor(version, 1, 56);
1432
+ }
1433
+ cleanupTempFile() {
1434
+ if (this.testListPath !== null) {
1435
+ const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
1436
+ del(this.testListPath);
1437
+ this.testListPath = null;
1438
+ }
1439
+ }
1440
+ start(filters) {
1441
+ const ctx = this.deps.getRunContext();
1442
+ if (ctx === null) return { ok: false, reason: "no-config" };
1443
+ if (this.child !== null) return { ok: false, reason: "already-running" };
1444
+ if (filters.tests !== void 0 && filters.tests.length === 0) {
1445
+ return { ok: false, reason: "no-tests" };
1446
+ }
1447
+ const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
1448
+ const reporterModule = resolveReporter(ctx.cwd);
1449
+ const tests = filters.tests;
1450
+ const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
1451
+ const args = ["test", "--config", ctx.configFile];
1452
+ if (reporterModule !== null) args.push("--reporter", reporterModule);
1453
+ if (useTestList && tests !== void 0) {
1454
+ const content = buildTestListEntries(tests).join("\n");
1455
+ const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
1456
+ this.testListPath = writeTemp(content);
1457
+ args.push("--test-list", this.testListPath);
1458
+ } else if (tests !== void 0 && tests.length > 0) {
1459
+ const project = sharedProject(tests);
1460
+ if (project !== void 0) args.push("--project", project);
1461
+ for (const d of tests) args.push(descriptorLocation(d));
1462
+ }
1463
+ const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
1464
+ const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
1465
+ let child;
1466
+ try {
1467
+ child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
1468
+ } catch (err) {
1469
+ this.cleanupTempFile();
1470
+ throw err;
1471
+ }
1472
+ this.child = child;
1473
+ child.on("exit", (code) => {
1474
+ this.handleChildExit(code);
1475
+ });
1476
+ child.on("error", () => {
1477
+ this.handleChildExit(null);
1478
+ });
1479
+ this.deps.setReportRunning(true);
1480
+ this.deps.setRunFiltered?.(filters.tests !== void 0);
1481
+ this.deps.broadcast({ type: "run-status", data: { running: true } });
1482
+ return { ok: true };
1483
+ }
1484
+ stop() {
1485
+ if (this.child === null) return { ok: false, reason: "not-running" };
1486
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1487
+ this.child.kill("SIGTERM");
1488
+ this.sigkillTimer = this.deps.timers.setTimeout(() => {
1489
+ if (this.child !== null) this.child.kill("SIGKILL");
1490
+ }, STOP_GRACE_MS);
1491
+ return { ok: true };
1492
+ }
1493
+ dispose() {
1494
+ if (this.child === null) return;
1495
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1496
+ this.sigkillTimer = null;
1497
+ this.child.kill("SIGKILL");
1498
+ this.cleanupTempFile();
1499
+ }
1500
+ handleChildExit(code) {
1501
+ if (this.child === null) return;
1502
+ if (this.sigkillTimer !== null) {
1503
+ this.deps.timers.clearTimeout(this.sigkillTimer);
1504
+ this.sigkillTimer = null;
1505
+ }
1506
+ this.child = null;
1507
+ this.cleanupTempFile();
1508
+ if (code !== null && code !== 0) {
1509
+ console.warn(`[RunController] playwright test exited with code ${code}`);
1510
+ }
1511
+ this.deps.setReportRunning(false);
1512
+ this.deps.broadcast({ type: "run-status", data: { running: false } });
1513
+ }
1514
+ };
1515
+ function createRealSpawn() {
1516
+ return (cmd, args, opts) => {
1517
+ const cp = (0, import_child_process.spawn)(cmd, args, opts);
1518
+ return {
1519
+ on: (event, cb) => {
1520
+ cp.on(event, cb);
1521
+ },
1522
+ kill: (signal) => {
1523
+ const sig = KNOWN_SIGNALS[signal];
1524
+ if (sig !== void 0) cp.kill(sig);
1525
+ }
1526
+ };
1527
+ };
1528
+ }
1529
+ function createRealTimers() {
1530
+ const pending = [];
1531
+ return {
1532
+ setTimeout: (fn, ms) => {
1533
+ const id = setTimeout(fn, ms);
1534
+ pending.push(id);
1535
+ return id;
1536
+ },
1537
+ clearTimeout: () => {
1538
+ for (const h of pending.splice(0)) clearTimeout(h);
1539
+ }
1540
+ };
1541
+ }
1542
+
1543
+ // src/server/app.ts
1544
+ var import_meta2 = {};
1144
1545
  function createReportData(options) {
1145
1546
  return {
1146
1547
  isRunning: false,
@@ -1166,37 +1567,6 @@ async function loadReport(reportPath, reportData) {
1166
1567
  console.log("No report.json found, using empty state");
1167
1568
  }
1168
1569
  }
1169
- async function readOfflineReport(filePath) {
1170
- try {
1171
- const raw = await readJsonFile(filePath);
1172
- if (raw === null) {
1173
- return null;
1174
- }
1175
- const parsed = parseOfflineReport(raw);
1176
- if (parsed !== null) {
1177
- console.log(`[Server] Loading offline report: ${filePath}`);
1178
- return parsed;
1179
- }
1180
- } catch {
1181
- }
1182
- return null;
1183
- }
1184
- async function loadOfflineReports(offlineReportDir, reportData) {
1185
- const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
1186
- if (offlineReportPaths.length === 0) {
1187
- return;
1188
- }
1189
- const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
1190
- const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
1191
- const validReports = reports.filter((report) => report !== null);
1192
- if (validReports.length === 0) {
1193
- return;
1194
- }
1195
- reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
1196
- screenshotDir: reportData.screenshotDir,
1197
- screenshotsBaseUrl: "/screenshots/"
1198
- });
1199
- }
1200
1570
  async function handleParsedWebSocketMessage(ctx, msg) {
1201
1571
  switch (msg.type) {
1202
1572
  case "test-begin": {
@@ -1260,7 +1630,7 @@ function createWebSocketMessageHandler(getHandlerContext) {
1260
1630
  };
1261
1631
  }
1262
1632
  async function resolveStaticDir(staticDir) {
1263
- const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1633
+ const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta2.url));
1264
1634
  const candidates = staticDir === void 0 ? [
1265
1635
  currentDir,
1266
1636
  (0, import_path8.join)(currentDir, "dist"),
@@ -1287,23 +1657,29 @@ async function resolveReportPath(reportPath) {
1287
1657
  }
1288
1658
  return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
1289
1659
  }
1290
- function createRoutesContext(reportData, staticDir, saveReport, options) {
1291
- const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1292
- (root) => root !== void 0 && root !== ""
1293
- );
1294
- return {
1295
- reportData,
1296
- staticDir,
1297
- saveReport,
1298
- artifactRoots,
1299
- approvalRouting: {
1300
- configDir: options.configDir ?? process.cwd(),
1301
- playwrightTestDir: options.playwrightTestDir,
1302
- playwrightSnapshotDir: options.playwrightSnapshotDir,
1303
- playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1304
- playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1305
- }
1306
- };
1660
+ async function seedRunContext(routesContext, options) {
1661
+ if (routesContext.runContext !== void 0) {
1662
+ return;
1663
+ }
1664
+ const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
1665
+ if (configFile !== null) {
1666
+ routesContext.runContext = { configFile, cwd: process.cwd() };
1667
+ }
1668
+ }
1669
+ function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered) {
1670
+ return new RunController({
1671
+ getRunContext: () => routesContext.runContext ?? null,
1672
+ port,
1673
+ broadcast: (message) => {
1674
+ broadcastToBrowsers(wsClients, message);
1675
+ },
1676
+ setReportRunning: (running) => {
1677
+ reportData.isRunning = running;
1678
+ },
1679
+ setRunFiltered,
1680
+ spawn: createRealSpawn(),
1681
+ timers: createRealTimers()
1682
+ });
1307
1683
  }
1308
1684
  async function createServerApp(options = {}) {
1309
1685
  const port = options.port ?? 3e3;
@@ -1313,26 +1689,32 @@ async function createServerApp(options = {}) {
1313
1689
  const staticDir = await resolveStaticDir(options.staticDir);
1314
1690
  const wsClients = /* @__PURE__ */ new Set();
1315
1691
  const currentRunIds = /* @__PURE__ */ new Set();
1316
- async function saveReport() {
1317
- await writeJsonFile(reportFile, reportData);
1318
- }
1692
+ const saveReport = () => writeJsonFile(reportFile, reportData);
1319
1693
  const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
1694
+ await seedRunContext(routesContext, options);
1695
+ let isFilteredRun = false;
1696
+ const runController = createServerRunController(routesContext, wsClients, reportData, port, (filtered) => {
1697
+ isFilteredRun = filtered;
1698
+ });
1320
1699
  const getHandlerContext = () => ({
1321
1700
  reportData,
1322
1701
  wsClients,
1323
1702
  currentRunIds,
1703
+ isFilteredRun,
1324
1704
  saveReport,
1325
1705
  approvalRouting: routesContext.approvalRouting,
1326
- routesContext
1706
+ routesContext,
1707
+ runController
1327
1708
  });
1328
- const handleRequest = (req) => handleHttpRequest(routesContext, req);
1709
+ const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
1329
1710
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1330
1711
  await loadReport(reportFile, reportData);
1331
- await loadOfflineReports(offlineReportDir, reportData);
1712
+ await loadOfflineReports(reportData, offlineReportDir);
1332
1713
  return {
1333
1714
  port,
1334
1715
  wsClients,
1335
1716
  close: () => {
1717
+ runController.dispose();
1336
1718
  },
1337
1719
  handleRequest,
1338
1720
  handleWebSocketMessage