@crvy/rprtr 0.1.4 → 0.2.3

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 (46) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +8 -7
  3. package/dist/{chunk-KLGJSEKV.js → chunk-473CWZ4V.js} +539 -130
  4. package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +6 -4
  7. package/dist/index.css +26 -0
  8. package/dist/index.js +300 -79
  9. package/dist/offline-reports.d.ts +4 -0
  10. package/dist/offline-reports.d.ts.map +1 -1
  11. package/dist/report-state.d.ts +3 -1
  12. package/dist/report-state.d.ts.map +1 -1
  13. package/dist/reporter-utils.d.ts +0 -1
  14. package/dist/reporter-utils.d.ts.map +1 -1
  15. package/dist/reporter.cjs +186 -134
  16. package/dist/reporter.d.ts +1 -1
  17. package/dist/reporter.d.ts.map +1 -1
  18. package/dist/reporter.js +8 -6
  19. package/dist/schemas/http.d.ts +44 -0
  20. package/dist/schemas/http.d.ts.map +1 -0
  21. package/dist/schemas.d.ts +25 -6
  22. package/dist/schemas.d.ts.map +1 -1
  23. package/dist/server/app.d.ts +8 -1
  24. package/dist/server/app.d.ts.map +1 -1
  25. package/dist/server/handlers.d.ts +6 -0
  26. package/dist/server/handlers.d.ts.map +1 -1
  27. package/dist/server/playwright-config.d.ts +2 -0
  28. package/dist/server/playwright-config.d.ts.map +1 -0
  29. package/dist/server/report-persistence.d.ts +27 -0
  30. package/dist/server/report-persistence.d.ts.map +1 -0
  31. package/dist/server/routes-context.d.ts +13 -0
  32. package/dist/server/routes-context.d.ts.map +1 -0
  33. package/dist/server/routes.d.ts +6 -1
  34. package/dist/server/routes.d.ts.map +1 -1
  35. package/dist/server/run-controller.d.ts +87 -0
  36. package/dist/server/run-controller.d.ts.map +1 -0
  37. package/dist/server/run-routes.d.ts +3 -0
  38. package/dist/server/run-routes.d.ts.map +1 -0
  39. package/dist/server/shutdown.d.ts +17 -0
  40. package/dist/server/shutdown.d.ts.map +1 -0
  41. package/dist/server.cjs +733 -276
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +2 -2
  44. package/dist/types.d.ts +6 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +6 -5
package/dist/server.cjs CHANGED
@@ -35,13 +35,13 @@ __export(server_exports, {
35
35
  module.exports = __toCommonJS(server_exports);
36
36
 
37
37
  // src/server/app.ts
38
- var import_path7 = require("path");
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");
44
- var import_path = require("path");
42
+ var import_promises2 = require("fs/promises");
43
+ var import_path2 = require("path");
44
+ var import_p_limit = __toESM(require("p-limit"), 1);
45
45
 
46
46
  // src/path-utils.ts
47
47
  var posixAbsolutePattern = /^\//u;
@@ -186,7 +186,12 @@ function createMutableReportState(screenshotDir = "./screenshots") {
186
186
  function applyTestBeginEvent(state, data) {
187
187
  const { id, title, titlePath, browser, projectName, location } = data;
188
188
  state.currentRunIds.add(id);
189
- 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 = {
190
195
  id,
191
196
  titlePath: titlePath ?? [],
192
197
  browser: browser ?? "",
@@ -198,7 +203,8 @@ function applyTestBeginEvent(state, data) {
198
203
  location,
199
204
  status: "running"
200
205
  };
201
- return state.reportData.tests[id];
206
+ state.reportData.tests[id] = created;
207
+ return created;
202
208
  }
203
209
  function applyTestEndEvent(state, data, options = {}) {
204
210
  const test = state.reportData.tests[data.id];
@@ -236,11 +242,13 @@ function applyTestEndEvent(state, data, options = {}) {
236
242
  diffCount
237
243
  };
238
244
  }
239
- function finalizeRunEvent(state) {
245
+ function finalizeRunEvent(state, options = {}) {
240
246
  state.reportData.isRunning = false;
241
- state.reportData.tests = Object.fromEntries(
242
- Object.entries(state.reportData.tests).filter(([id]) => state.currentRunIds.has(id))
243
- );
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
+ }
244
252
  state.currentRunIds.clear();
245
253
  const runTests = Object.values(state.reportData.tests).filter((test) => test !== void 0);
246
254
  return {
@@ -251,169 +259,211 @@ function finalizeRunEvent(state) {
251
259
  }
252
260
 
253
261
  // src/schemas.ts
262
+ var import_zod2 = require("zod");
263
+
264
+ // src/schemas/http.ts
254
265
  var import_zod = require("zod");
255
- 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({
256
272
  file: import_zod.z.string(),
257
- 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())
258
277
  });
259
- var VisualSourceSchema = import_zod.z.enum(["comparison", "baseline-only", "declared-only"]);
260
- var ImagesSchema = import_zod.z.object({
261
- actual: import_zod.z.string().optional(),
262
- expect: import_zod.z.string().optional(),
263
- diff: import_zod.z.string().optional(),
264
- error: import_zod.z.string().optional(),
265
- source: VisualSourceSchema.optional()
278
+ var RunRequestBodySchema = import_zod.z.object({
279
+ tests: import_zod.z.array(RunTestDescriptorSchema).optional()
266
280
  });
267
- 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) }),
268
283
  import_zod.z.object({
269
- visualName: import_zod.z.string(),
270
- kind: import_zod.z.literal("named"),
271
- declaredName: import_zod.z.string(),
272
- snapshotBaseName: import_zod.z.string(),
273
- occurrenceIndex: import_zod.z.number()
274
- }),
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) }),
275
290
  import_zod.z.object({
276
- visualName: import_zod.z.string(),
277
- kind: import_zod.z.literal("unnamed"),
278
- 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()
279
322
  })
280
323
  ]);
281
- var AttachmentSchema = import_zod.z.object({
282
- name: import_zod.z.string(),
283
- path: import_zod.z.string(),
284
- 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()
285
328
  });
286
- var TestStatusSchema = import_zod.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
287
- var TestResultStatusSchema = import_zod.z.enum(["failed", "success", "pending"]);
288
- 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({
289
332
  status: TestResultStatusSchema,
290
- retries: import_zod.z.number(),
291
- images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
292
- visualDeclarations: import_zod.z.array(ScreenshotDeclarationSchema).optional(),
293
- error: import_zod.z.string().optional(),
294
- 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()
295
338
  });
296
- var TestDataSchema = import_zod.z.object({
297
- id: import_zod.z.string(),
298
- titlePath: import_zod.z.array(import_zod.z.string()),
299
- browser: import_zod.z.string(),
300
- projectName: import_zod.z.string().optional(),
301
- title: import_zod.z.string(),
302
- skip: import_zod.z.union([import_zod.z.boolean(), import_zod.z.string()]).optional(),
303
- 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(),
304
347
  status: TestStatusSchema.optional(),
305
- results: import_zod.z.array(TestResultSchema).optional(),
306
- approved: import_zod.z.record(import_zod.z.string(), import_zod.z.number()).nullable().optional(),
307
- 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(),
308
351
  location: LocationSchema.optional()
309
352
  });
310
353
  var CrvyRprtrTestSchema = TestDataSchema.extend({
311
- checked: import_zod.z.boolean()
354
+ checked: import_zod2.z.boolean()
312
355
  });
313
- var CrvyRprtrSuiteSchema = import_zod.z.lazy(
314
- () => import_zod.z.object({
315
- path: import_zod.z.array(import_zod.z.string()),
316
- 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(),
317
360
  status: TestStatusSchema.optional(),
318
- opened: import_zod.z.boolean(),
319
- checked: import_zod.z.boolean(),
320
- indeterminate: import_zod.z.boolean(),
321
- 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()
322
365
  })
323
366
  );
324
- var IncomingWebSocketMessageSchema = import_zod.z.object({
325
- type: import_zod.z.enum(["test-begin", "test-end", "run-end", "approve", "sync", "register"]),
326
- 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()
327
370
  });
328
- var WebSocketMessageSchema = import_zod.z.discriminatedUnion("type", [
329
- import_zod.z.object({ type: import_zod.z.literal("test-begin"), data: TestDataSchema }),
330
- import_zod.z.object({ type: import_zod.z.literal("test-update"), data: TestDataSchema }),
331
- import_zod.z.object({
332
- type: import_zod.z.literal("run-end"),
333
- data: import_zod.z.object({
334
- status: import_zod.z.enum(["passed", "failed", "skipped"]),
335
- 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())
336
379
  })
337
380
  }),
338
- import_zod.z.object({
339
- type: import_zod.z.literal("sync"),
340
- data: import_zod.z.object({
341
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
342
- 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()
343
386
  })
344
387
  }),
345
- 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
+ })
346
395
  ]);
347
- var TestBeginDataSchema = import_zod.z.object({
348
- id: import_zod.z.string(),
349
- title: import_zod.z.string(),
350
- titlePath: import_zod.z.array(import_zod.z.string()),
351
- browser: import_zod.z.string(),
352
- 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(),
353
402
  location: LocationSchema
354
403
  });
355
- var TestEndDataSchema = import_zod.z.object({
356
- id: import_zod.z.string(),
357
- status: import_zod.z.enum(["passed", "failed", "skipped"]),
358
- attachments: import_zod.z.array(AttachmentSchema),
359
- visualNames: import_zod.z.array(import_zod.z.string()).default([]),
360
- 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(
361
410
  (value) => value === null ? void 0 : value,
362
- import_zod.z.array(ScreenshotDeclarationSchema).optional()
411
+ import_zod2.z.array(ScreenshotDeclarationSchema).optional()
363
412
  ),
364
- error: import_zod.z.string().optional(),
365
- duration: import_zod.z.number().optional()
413
+ error: import_zod2.z.string().optional(),
414
+ duration: import_zod2.z.number().optional()
366
415
  });
367
- var RunEndDataSchema = import_zod.z.object({
368
- status: import_zod.z.enum(["passed", "failed", "skipped"])
416
+ var RunEndDataSchema = import_zod2.z.object({
417
+ status: import_zod2.z.enum(["passed", "failed", "skipped"])
369
418
  });
370
- var RegisterDataSchema = import_zod.z.object({
371
- playwrightSnapshotDir: import_zod.z.string().optional(),
372
- playwrightTestDir: import_zod.z.string().optional(),
373
- playwrightSnapshotPathTemplate: import_zod.z.string().optional(),
374
- playwrightToHaveScreenshotPathTemplate: import_zod.z.string().optional()
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()
375
426
  });
376
- var ReportDataSchema = import_zod.z.object({
377
- isRunning: import_zod.z.boolean(),
378
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
379
- browsers: import_zod.z.array(import_zod.z.string()),
380
- isUpdateMode: import_zod.z.boolean(),
381
- screenshotDir: import_zod.z.string()
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()
382
433
  });
383
- var LoadedReportDataSchema = import_zod.z.object({
384
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema).optional(),
385
- isUpdateMode: import_zod.z.boolean().optional()
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()
386
437
  });
387
- var OfflineEventSchema = import_zod.z.object({
388
- type: import_zod.z.enum(["test-begin", "test-end", "run-end"]),
389
- data: import_zod.z.unknown(),
390
- timestamp: import_zod.z.number(),
391
- workerIndex: import_zod.z.number()
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()
392
443
  });
393
- var OfflineReportSchema = import_zod.z.object({
394
- version: import_zod.z.number(),
395
- generatedAt: import_zod.z.string(),
396
- workers: import_zod.z.number(),
397
- events: import_zod.z.array(OfflineEventSchema)
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)
398
449
  });
399
- var ApproveRequestBodySchema = import_zod.z.object({
400
- id: import_zod.z.string(),
401
- retry: import_zod.z.number(),
402
- image: import_zod.z.string()
403
- });
404
- var ReportApiResponseSchema = import_zod.z.object({
405
- tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
406
- 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()
407
455
  });
408
- var ClientBootstrapDataSchema = import_zod.z.object({
456
+ var ClientBootstrapDataSchema = import_zod2.z.object({
409
457
  report: ReportApiResponseSchema.extend({
410
- isUpdateMode: import_zod.z.boolean()
458
+ isUpdateMode: import_zod2.z.boolean()
411
459
  }),
412
- liveUpdates: import_zod.z.boolean(),
413
- approvalEnabled: import_zod.z.boolean(),
414
- 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()
415
465
  });
416
- 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"]);
417
467
  function safeParse(schema, data) {
418
468
  const result = schema.safeParse(data);
419
469
  if (result.success) {
@@ -422,70 +472,15 @@ function safeParse(schema, data) {
422
472
  return null;
423
473
  }
424
474
 
425
- // src/offline-reports.ts
426
- var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
427
- async function findOfflineReportPaths(searchDir) {
428
- try {
429
- const entries = await (0, import_promises.readdir)(searchDir);
430
- return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => (0, import_path.join)(searchDir, entry));
431
- } catch (error) {
432
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
433
- return [];
434
- }
435
- throw error;
436
- }
437
- }
438
- function parseOfflineReport(value) {
439
- const parsed = safeParse(OfflineReportSchema, value);
440
- if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
441
- return null;
442
- }
443
- return parsed;
444
- }
445
- function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
446
- const state = createMutableReportState(options.screenshotDir);
447
- let shouldFinalize = false;
448
- for (const report of offlineReports) {
449
- for (const event of report.events) {
450
- switch (event.type) {
451
- case "test-begin": {
452
- const parsed = safeParse(TestBeginDataSchema, event.data);
453
- if (parsed !== null) {
454
- applyTestBeginEvent(state, parsed);
455
- }
456
- break;
457
- }
458
- case "test-end": {
459
- const parsed = safeParse(TestEndDataSchema, event.data);
460
- if (parsed !== null) {
461
- applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
462
- }
463
- break;
464
- }
465
- case "run-end":
466
- shouldFinalize = true;
467
- break;
468
- }
469
- }
470
- }
471
- if (shouldFinalize) {
472
- finalizeRunEvent(state);
473
- }
474
- return {
475
- ...existingTests,
476
- ...state.reportData.tests
477
- };
478
- }
479
-
480
475
  // src/server/file-utils.ts
481
- var import_promises2 = require("fs/promises");
482
- var import_path2 = require("path");
476
+ var import_promises = require("fs/promises");
477
+ var import_path = require("path");
483
478
  function isFileNotFound(error) {
484
479
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
485
480
  }
486
481
  async function fileExists(filePath) {
487
482
  try {
488
- await (0, import_promises2.access)(filePath);
483
+ await (0, import_promises.access)(filePath);
489
484
  return true;
490
485
  } catch (error) {
491
486
  if (isFileNotFound(error)) {
@@ -496,7 +491,7 @@ async function fileExists(filePath) {
496
491
  }
497
492
  async function isDirectory(filePath) {
498
493
  try {
499
- const stats = await (0, import_promises2.stat)(filePath);
494
+ const stats = await (0, import_promises.stat)(filePath);
500
495
  return stats.isDirectory();
501
496
  } catch (error) {
502
497
  if (isFileNotFound(error)) {
@@ -507,7 +502,7 @@ async function isDirectory(filePath) {
507
502
  }
508
503
  async function readJsonFile(filePath) {
509
504
  try {
510
- const raw = await (0, import_promises2.readFile)(filePath, "utf8");
505
+ const raw = await (0, import_promises.readFile)(filePath, "utf8");
511
506
  if (raw.trim() === "") {
512
507
  return null;
513
508
  }
@@ -520,16 +515,16 @@ async function readJsonFile(filePath) {
520
515
  }
521
516
  }
522
517
  async function writeJsonFile(filePath, value) {
523
- await (0, import_promises2.mkdir)((0, import_path2.dirname)(filePath), { recursive: true });
524
- 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)}
525
520
  `);
526
521
  }
527
522
  async function copyFilePortable(sourcePath, destinationPath) {
528
- await (0, import_promises2.mkdir)((0, import_path2.dirname)(destinationPath), { recursive: true });
529
- 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);
530
525
  }
531
526
  function inferContentType(filePath) {
532
- switch ((0, import_path2.extname)(filePath).toLowerCase()) {
527
+ switch ((0, import_path.extname)(filePath).toLowerCase()) {
533
528
  case ".css":
534
529
  return "text/css";
535
530
  case ".gif":
@@ -557,7 +552,7 @@ function inferContentType(filePath) {
557
552
  }
558
553
  async function respondWithFile(filePath, contentType) {
559
554
  try {
560
- const file = await (0, import_promises2.readFile)(filePath);
555
+ const file = await (0, import_promises.readFile)(filePath);
561
556
  const resolvedContentType = contentType ?? inferContentType(filePath);
562
557
  const headers = { "X-Content-Type-Options": "nosniff" };
563
558
  if (resolvedContentType !== void 0) {
@@ -572,6 +567,93 @@ async function respondWithFile(filePath, contentType) {
572
567
  }
573
568
  }
574
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
+
575
657
  // src/server/handlers.ts
576
658
  var import_fs2 = require("fs");
577
659
 
@@ -872,6 +954,7 @@ function handleArtifactRoute(ctx, req, pathname) {
872
954
  function handleTestBegin(ctx, data) {
873
955
  const test = applyTestBeginEvent(ctx, data);
874
956
  ctx.reportData.isRunning = true;
957
+ ctx.scheduleReportSave();
875
958
  console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
876
959
  const message = { type: "test-begin", data: test };
877
960
  broadcastToBrowsers(ctx.wsClients, message);
@@ -910,12 +993,13 @@ function handleTestEnd(ctx, data) {
910
993
  const errNote = data.error !== null && data.error !== void 0 ? `
911
994
  Error: ${data.error}` : "";
912
995
  console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
996
+ ctx.scheduleReportSave();
913
997
  const message = { type: "test-update", data: test };
914
998
  broadcastToBrowsers(ctx.wsClients, message);
915
999
  }
916
1000
  async function handleRunEnd(ctx, data) {
917
- const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
918
- const { passed, failed, pending } = finalizeRunEvent(ctx);
1001
+ const removedTestIds = ctx.isFilteredRun ? [] : Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
1002
+ const { passed, failed, pending } = finalizeRunEvent(ctx, { preserveNonCurrent: ctx.isFilteredRun });
919
1003
  await ctx.saveReport();
920
1004
  console.log(`
921
1005
  Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
@@ -968,16 +1052,148 @@ function handleRegister(ctx, data) {
968
1052
  ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
969
1053
  }
970
1054
  }
1055
+ if (data.configFile !== void 0 && data.cwd !== void 0) {
1056
+ ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
1057
+ }
971
1058
  console.log("[Server] Reporter registered with config:", {
972
1059
  playwrightSnapshotDir: data.playwrightSnapshotDir,
973
1060
  playwrightTestDir: data.playwrightTestDir
974
1061
  });
975
1062
  }
976
1063
 
977
- // src/server/routes.ts
1064
+ // src/server/playwright-config.ts
978
1065
  var import_path6 = require("path");
1066
+ var CONFIG_FILES = [
1067
+ "playwright.config.ts",
1068
+ "playwright.config.mts",
1069
+ "playwright.config.cts",
1070
+ "playwright.config.js",
1071
+ "playwright.config.mjs",
1072
+ "playwright.config.cjs"
1073
+ ];
1074
+ async function resolvePlaywrightConfig(cwd) {
1075
+ const matches = await Promise.all(
1076
+ CONFIG_FILES.map(async (file) => {
1077
+ const candidate = (0, import_path6.join)(cwd, file);
1078
+ return await fileExists(candidate) ? candidate : null;
1079
+ })
1080
+ );
1081
+ return matches.find((path) => path !== null) ?? null;
1082
+ }
1083
+
1084
+ // src/server/report-persistence.ts
1085
+ function createDebouncedSaver(save, delayMs, setTimeoutFn = (fn, ms) => setTimeout(fn, ms), clearTimeoutFn = (handle) => {
1086
+ if (handle !== null) clearTimeout(handle);
1087
+ }) {
1088
+ let timer = null;
1089
+ let dirty = false;
1090
+ let inFlight = null;
1091
+ const runSave = () => {
1092
+ dirty = false;
1093
+ inFlight = save();
1094
+ return inFlight.finally(() => {
1095
+ inFlight = null;
1096
+ });
1097
+ };
1098
+ return {
1099
+ schedule() {
1100
+ dirty = true;
1101
+ if (timer !== null) clearTimeoutFn(timer);
1102
+ timer = setTimeoutFn(() => {
1103
+ timer = null;
1104
+ void runSave();
1105
+ }, delayMs);
1106
+ },
1107
+ async flush() {
1108
+ if (timer !== null) {
1109
+ clearTimeoutFn(timer);
1110
+ timer = null;
1111
+ }
1112
+ if (inFlight !== null) {
1113
+ await inFlight;
1114
+ }
1115
+ if (dirty) {
1116
+ await runSave();
1117
+ }
1118
+ },
1119
+ cancel() {
1120
+ if (timer !== null) {
1121
+ clearTimeoutFn(timer);
1122
+ timer = null;
1123
+ }
1124
+ dirty = false;
1125
+ }
1126
+ };
1127
+ }
1128
+ function createReportPersistence(reportFile, reportData) {
1129
+ const reportSaver = createDebouncedSaver(() => writeJsonFile(reportFile, reportData), 250);
1130
+ return {
1131
+ saveReport: () => reportSaver.flush(),
1132
+ scheduleReportSave: () => {
1133
+ reportSaver.schedule();
1134
+ },
1135
+ dispose: () => reportSaver.flush()
1136
+ };
1137
+ }
1138
+
1139
+ // src/server/routes-context.ts
1140
+ function createRoutesContext(reportData, staticDir, saveReport, options) {
1141
+ const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1142
+ (root) => root !== void 0 && root !== ""
1143
+ );
1144
+ return {
1145
+ reportData,
1146
+ staticDir,
1147
+ saveReport,
1148
+ artifactRoots,
1149
+ approvalRouting: {
1150
+ configDir: options.configDir ?? process.cwd(),
1151
+ playwrightTestDir: options.playwrightTestDir,
1152
+ playwrightSnapshotDir: options.playwrightSnapshotDir,
1153
+ playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1154
+ playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1155
+ },
1156
+ runContext: void 0
1157
+ };
1158
+ }
1159
+
1160
+ // src/server/routes.ts
1161
+ var import_path7 = require("path");
1162
+
1163
+ // src/server/run-routes.ts
1164
+ function handleRunRoutes(pathname, method, runController, req) {
1165
+ if (pathname === "/api/run" && method === "POST") {
1166
+ return handleApiRun(runController, req);
1167
+ }
1168
+ if (pathname === "/api/stop" && method === "POST") {
1169
+ return Promise.resolve(handleApiStop(runController));
1170
+ }
1171
+ return null;
1172
+ }
1173
+ async function handleApiRun(runController, req) {
1174
+ let body = {};
1175
+ try {
1176
+ body = await req.json();
1177
+ } catch {
1178
+ }
1179
+ const parsed = safeParse(RunRequestBodySchema, body);
1180
+ if (parsed === null) {
1181
+ return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
1182
+ }
1183
+ const result = runController.start(parsed);
1184
+ if (result.ok) return Response.json(result);
1185
+ const status = result.reason === "no-tests" ? 400 : 409;
1186
+ return Response.json(result, { status });
1187
+ }
1188
+ function handleApiStop(runController) {
1189
+ const result = runController.stop();
1190
+ if (result.ok) return Response.json(result);
1191
+ return Response.json(result, { status: 409 });
1192
+ }
1193
+
1194
+ // src/server/routes.ts
979
1195
  async function handleRoot(ctx) {
980
- const html = await respondWithFile((0, import_path6.join)(ctx.staticDir, "index.html"), "text/html");
1196
+ const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
981
1197
  return html ?? new Response("Not Found", { status: 404 });
982
1198
  }
983
1199
  async function handleAppCss() {
@@ -992,12 +1208,15 @@ async function handleSrcFiles(req) {
992
1208
  return file ?? new Response("Not Found", { status: 404 });
993
1209
  }
994
1210
  function handleApiReport(ctx) {
995
- return Response.json(ctx.reportData);
1211
+ return Response.json({
1212
+ ...ctx.reportData,
1213
+ runEnabled: ctx.runContext !== void 0
1214
+ });
996
1215
  }
997
1216
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
998
1217
  function actualPathFromUrl(ctx, actualUrl) {
999
1218
  if (actualUrl.startsWith("/screenshots/")) {
1000
- return (0, import_path6.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1219
+ return (0, import_path7.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1001
1220
  }
1002
1221
  if (actualUrl.startsWith("/file/")) {
1003
1222
  return decodeURIComponent(actualUrl.slice("/file/".length));
@@ -1119,12 +1338,12 @@ async function handleScreenshots(ctx, req) {
1119
1338
  }
1120
1339
  async function handleDist(ctx, req) {
1121
1340
  const path = new URL(req.url).pathname.slice("/dist/".length);
1122
- const filePath = (0, import_path6.join)(ctx.staticDir, path);
1341
+ const filePath = (0, import_path7.join)(ctx.staticDir, path);
1123
1342
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
1124
1343
  const file = await respondWithFile(filePath, contentType);
1125
1344
  return file ?? new Response("Not Found", { status: 404 });
1126
1345
  }
1127
- function handleHttpRequest(ctx, req) {
1346
+ function handleHttpRequest(ctx, req, runController) {
1128
1347
  const pathname = new URL(req.url).pathname;
1129
1348
  if (pathname === "/") {
1130
1349
  return handleRoot(ctx);
@@ -1144,6 +1363,10 @@ function handleHttpRequest(ctx, req) {
1144
1363
  if (pathname === "/api/approve-all" && req.method === "POST") {
1145
1364
  return handleApiApproveAll(ctx);
1146
1365
  }
1366
+ const runResponse = handleRunRoutes(pathname, req.method, runController, req);
1367
+ if (runResponse !== null) {
1368
+ return runResponse;
1369
+ }
1147
1370
  if (pathname.startsWith("/api/images/")) {
1148
1371
  return handleApiImages(req);
1149
1372
  }
@@ -1160,9 +1383,221 @@ function handleHttpRequest(ctx, req) {
1160
1383
  return Promise.resolve(new Response("Not Found", { status: 404 }));
1161
1384
  }
1162
1385
 
1163
- // src/server/app.ts
1386
+ // src/server/run-controller.ts
1387
+ var import_child_process = require("child_process");
1388
+ var import_node_fs = require("node:fs");
1389
+ var import_node_module = require("node:module");
1390
+ var import_node_os = require("node:os");
1391
+ var import_node_path = require("node:path");
1392
+ var import_commands = require("package-manager-detector/commands");
1393
+ var import_detect = require("package-manager-detector/detect");
1164
1394
  var import_meta = { url: require("url").pathToFileURL(__filename).href };
1165
- var MAX_CONCURRENT_FILE_OPS = 5;
1395
+ var STOP_GRACE_MS = 5e3;
1396
+ var KNOWN_SIGNALS = {
1397
+ SIGTERM: "SIGTERM",
1398
+ SIGKILL: "SIGKILL"
1399
+ };
1400
+ function resolvePlaywrightLaunch(cwd, playwrightArgs) {
1401
+ const agent = (0, import_detect.getUserAgent)();
1402
+ const resolved = agent === null ? null : (0, import_commands.resolveCommand)(agent, "execute-local", ["playwright", ...playwrightArgs]);
1403
+ if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
1404
+ return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
1405
+ }
1406
+ function descriptorLocation(d) {
1407
+ return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
1408
+ }
1409
+ function sharedProject(tests) {
1410
+ const names = new Set(tests.map((t) => t.projectName ?? ""));
1411
+ if (names.size === 1) {
1412
+ const name = [...names][0];
1413
+ return name === "" ? void 0 : name;
1414
+ }
1415
+ return void 0;
1416
+ }
1417
+ function gteMinor(version, major, minor) {
1418
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
1419
+ if (match === null) return false;
1420
+ const maj = parseInt(match[1], 10);
1421
+ const min = parseInt(match[2], 10);
1422
+ if (maj !== major) return maj > major;
1423
+ return min >= minor;
1424
+ }
1425
+ function resolvePlaywrightVersion(cwd) {
1426
+ try {
1427
+ const req = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json"));
1428
+ const pkgPath = req.resolve("@playwright/test/package.json");
1429
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)(pkgPath, "utf8"));
1430
+ return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
1431
+ } catch {
1432
+ return null;
1433
+ }
1434
+ }
1435
+ function buildTestListEntries(tests) {
1436
+ return tests.map((d) => {
1437
+ const loc = descriptorLocation(d);
1438
+ const title = d.titlePath.join(" \u203A ");
1439
+ const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
1440
+ return `${prefix}${loc} \u203A ${title}`;
1441
+ });
1442
+ }
1443
+ function defaultWriteTempFile(content) {
1444
+ const path = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
1445
+ (0, import_node_fs.writeFileSync)(path, content, "utf8");
1446
+ return path;
1447
+ }
1448
+ function defaultDeleteTempFile(path) {
1449
+ try {
1450
+ (0, import_node_fs.unlinkSync)(path);
1451
+ } catch {
1452
+ }
1453
+ }
1454
+ function resolveReporterDefault(cwd) {
1455
+ try {
1456
+ return (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json")).resolve("@crvy/rprtr");
1457
+ } catch {
1458
+ }
1459
+ try {
1460
+ return (0, import_node_module.createRequire)(import_meta.url).resolve("@crvy/rprtr");
1461
+ } catch {
1462
+ return null;
1463
+ }
1464
+ }
1465
+ function buildSpawnEnv(port) {
1466
+ const env = {};
1467
+ for (const [key, value] of Object.entries(process.env)) {
1468
+ if (key === "CI") continue;
1469
+ env[key] = value;
1470
+ }
1471
+ env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
1472
+ env.PLAYWRIGHT_HTML_OPEN = "never";
1473
+ return env;
1474
+ }
1475
+ var RunController = class {
1476
+ constructor(deps) {
1477
+ this.deps = deps;
1478
+ }
1479
+ child = null;
1480
+ sigkillTimer = null;
1481
+ testListPath = null;
1482
+ get isRunning() {
1483
+ return this.child !== null;
1484
+ }
1485
+ supportsTestList(cwd) {
1486
+ const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
1487
+ const version = getVersion(cwd);
1488
+ return version !== null && gteMinor(version, 1, 56);
1489
+ }
1490
+ cleanupTempFile() {
1491
+ if (this.testListPath !== null) {
1492
+ const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
1493
+ del(this.testListPath);
1494
+ this.testListPath = null;
1495
+ }
1496
+ }
1497
+ start(filters) {
1498
+ const ctx = this.deps.getRunContext();
1499
+ if (ctx === null) return { ok: false, reason: "no-config" };
1500
+ if (this.child !== null) return { ok: false, reason: "already-running" };
1501
+ if (filters.tests !== void 0 && filters.tests.length === 0) {
1502
+ return { ok: false, reason: "no-tests" };
1503
+ }
1504
+ const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
1505
+ const reporterModule = resolveReporter(ctx.cwd);
1506
+ const tests = filters.tests;
1507
+ const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
1508
+ const args = ["test", "--config", ctx.configFile];
1509
+ if (reporterModule !== null) args.push("--reporter", reporterModule);
1510
+ if (useTestList && tests !== void 0) {
1511
+ const content = buildTestListEntries(tests).join("\n");
1512
+ const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
1513
+ this.testListPath = writeTemp(content);
1514
+ args.push("--test-list", this.testListPath);
1515
+ } else if (tests !== void 0 && tests.length > 0) {
1516
+ const project = sharedProject(tests);
1517
+ if (project !== void 0) args.push("--project", project);
1518
+ for (const d of tests) args.push(descriptorLocation(d));
1519
+ }
1520
+ const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
1521
+ const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
1522
+ let child;
1523
+ try {
1524
+ child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
1525
+ } catch (err) {
1526
+ this.cleanupTempFile();
1527
+ throw err;
1528
+ }
1529
+ this.child = child;
1530
+ child.on("exit", (code) => {
1531
+ this.handleChildExit(code);
1532
+ });
1533
+ child.on("error", () => {
1534
+ this.handleChildExit(null);
1535
+ });
1536
+ this.deps.setReportRunning(true);
1537
+ this.deps.setRunFiltered?.(filters.tests !== void 0);
1538
+ this.deps.broadcast({ type: "run-status", data: { running: true } });
1539
+ return { ok: true };
1540
+ }
1541
+ stop() {
1542
+ if (this.child === null) return { ok: false, reason: "not-running" };
1543
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1544
+ this.child.kill("SIGTERM");
1545
+ this.sigkillTimer = this.deps.timers.setTimeout(() => {
1546
+ if (this.child !== null) this.child.kill("SIGKILL");
1547
+ }, STOP_GRACE_MS);
1548
+ return { ok: true };
1549
+ }
1550
+ dispose() {
1551
+ if (this.child === null) return;
1552
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1553
+ this.sigkillTimer = null;
1554
+ this.child.kill("SIGKILL");
1555
+ this.cleanupTempFile();
1556
+ }
1557
+ handleChildExit(code) {
1558
+ if (this.child === null) return;
1559
+ if (this.sigkillTimer !== null) {
1560
+ this.deps.timers.clearTimeout(this.sigkillTimer);
1561
+ this.sigkillTimer = null;
1562
+ }
1563
+ this.child = null;
1564
+ this.cleanupTempFile();
1565
+ if (code !== null && code !== 0) {
1566
+ console.warn(`[RunController] playwright test exited with code ${code}`);
1567
+ }
1568
+ this.deps.setReportRunning(false);
1569
+ this.deps.broadcast({ type: "run-status", data: { running: false } });
1570
+ void this.deps.saveReport?.();
1571
+ }
1572
+ };
1573
+ function createRealSpawn() {
1574
+ return (cmd, args, opts) => {
1575
+ const cp = (0, import_child_process.spawn)(cmd, args, opts);
1576
+ return {
1577
+ on: (event, cb) => cp.on(event, cb),
1578
+ kill: (signal) => {
1579
+ const sig = KNOWN_SIGNALS[signal];
1580
+ if (sig !== void 0) cp.kill(sig);
1581
+ }
1582
+ };
1583
+ };
1584
+ }
1585
+ function createRealTimers() {
1586
+ const pending = [];
1587
+ return {
1588
+ setTimeout: (fn, ms) => {
1589
+ const id = setTimeout(fn, ms);
1590
+ pending.push(id);
1591
+ return id;
1592
+ },
1593
+ clearTimeout: () => {
1594
+ for (const h of pending.splice(0)) clearTimeout(h);
1595
+ }
1596
+ };
1597
+ }
1598
+
1599
+ // src/server/app.ts
1600
+ var import_meta2 = {};
1166
1601
  function createReportData(options) {
1167
1602
  return {
1168
1603
  isRunning: false,
@@ -1188,37 +1623,6 @@ async function loadReport(reportPath, reportData) {
1188
1623
  console.log("No report.json found, using empty state");
1189
1624
  }
1190
1625
  }
1191
- async function readOfflineReport(filePath) {
1192
- try {
1193
- const raw = await readJsonFile(filePath);
1194
- if (raw === null) {
1195
- return null;
1196
- }
1197
- const parsed = parseOfflineReport(raw);
1198
- if (parsed !== null) {
1199
- console.log(`[Server] Loading offline report: ${filePath}`);
1200
- return parsed;
1201
- }
1202
- } catch {
1203
- }
1204
- return null;
1205
- }
1206
- async function loadOfflineReports(offlineReportDir, reportData) {
1207
- const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
1208
- if (offlineReportPaths.length === 0) {
1209
- return;
1210
- }
1211
- const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
1212
- const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
1213
- const validReports = reports.filter((report) => report !== null);
1214
- if (validReports.length === 0) {
1215
- return;
1216
- }
1217
- reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
1218
- screenshotDir: reportData.screenshotDir,
1219
- screenshotsBaseUrl: "/screenshots/"
1220
- });
1221
- }
1222
1626
  async function handleParsedWebSocketMessage(ctx, msg) {
1223
1627
  switch (msg.type) {
1224
1628
  case "test-begin": {
@@ -1282,19 +1686,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1282
1686
  };
1283
1687
  }
1284
1688
  async function resolveStaticDir(staticDir) {
1285
- const currentDir = (0, import_path7.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1689
+ const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta2.url));
1286
1690
  const candidates = staticDir === void 0 ? [
1287
1691
  currentDir,
1288
- (0, import_path7.join)(currentDir, "dist"),
1289
- (0, import_path7.join)(currentDir, "..", "dist"),
1290
- (0, import_path7.join)(currentDir, "..", "..", "dist"),
1291
- (0, import_path7.join)(currentDir, ".."),
1292
- (0, import_path7.join)(currentDir, "..", "..")
1293
- ] : [staticDir, (0, import_path7.join)(staticDir, "dist")];
1692
+ (0, import_path8.join)(currentDir, "dist"),
1693
+ (0, import_path8.join)(currentDir, "..", "dist"),
1694
+ (0, import_path8.join)(currentDir, "..", "..", "dist"),
1695
+ (0, import_path8.join)(currentDir, ".."),
1696
+ (0, import_path8.join)(currentDir, "..", "..")
1697
+ ] : [staticDir, (0, import_path8.join)(staticDir, "dist")];
1294
1698
  const resolvedCandidates = await Promise.all(
1295
1699
  candidates.map(async (candidate) => ({
1296
1700
  candidate,
1297
- exists: await fileExists((0, import_path7.join)(candidate, "index.html"))
1701
+ exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
1298
1702
  }))
1299
1703
  );
1300
1704
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1305,27 +1709,34 @@ async function resolveStaticDir(staticDir) {
1305
1709
  }
1306
1710
  async function resolveReportPath(reportPath) {
1307
1711
  if (await isDirectory(reportPath)) {
1308
- return { reportFile: (0, import_path7.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1712
+ return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1309
1713
  }
1310
- return { reportFile: reportPath, offlineReportDir: (0, import_path7.dirname)(reportPath) };
1714
+ return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
1311
1715
  }
1312
- function createRoutesContext(reportData, staticDir, saveReport, options) {
1313
- const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
1314
- (root) => root !== void 0 && root !== ""
1315
- );
1316
- return {
1317
- reportData,
1318
- staticDir,
1716
+ async function seedRunContext(routesContext, options) {
1717
+ if (routesContext.runContext !== void 0) {
1718
+ return;
1719
+ }
1720
+ const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
1721
+ if (configFile !== null) {
1722
+ routesContext.runContext = { configFile, cwd: process.cwd() };
1723
+ }
1724
+ }
1725
+ function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport) {
1726
+ return new RunController({
1727
+ getRunContext: () => routesContext.runContext ?? null,
1728
+ port,
1729
+ broadcast: (message) => {
1730
+ broadcastToBrowsers(wsClients, message);
1731
+ },
1732
+ setReportRunning: (running) => {
1733
+ reportData.isRunning = running;
1734
+ },
1735
+ setRunFiltered,
1319
1736
  saveReport,
1320
- artifactRoots,
1321
- approvalRouting: {
1322
- configDir: options.configDir ?? process.cwd(),
1323
- playwrightTestDir: options.playwrightTestDir,
1324
- playwrightSnapshotDir: options.playwrightSnapshotDir,
1325
- playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1326
- playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1327
- }
1328
- };
1737
+ spawn: createRealSpawn(),
1738
+ timers: createRealTimers()
1739
+ });
1329
1740
  }
1330
1741
  async function createServerApp(options = {}) {
1331
1742
  const port = options.port ?? 3e3;
@@ -1335,31 +1746,49 @@ async function createServerApp(options = {}) {
1335
1746
  const staticDir = await resolveStaticDir(options.staticDir);
1336
1747
  const wsClients = /* @__PURE__ */ new Set();
1337
1748
  const currentRunIds = /* @__PURE__ */ new Set();
1338
- async function saveReport() {
1339
- await writeJsonFile(reportFile, reportData);
1340
- }
1341
- const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
1749
+ const persistence = createReportPersistence(reportFile, reportData);
1750
+ const routesContext = createRoutesContext(reportData, staticDir, persistence.saveReport, options);
1751
+ await seedRunContext(routesContext, options);
1752
+ let isFilteredRun = false;
1753
+ const runController = createServerRunController(
1754
+ routesContext,
1755
+ wsClients,
1756
+ reportData,
1757
+ port,
1758
+ (filtered) => {
1759
+ isFilteredRun = filtered;
1760
+ },
1761
+ persistence.saveReport
1762
+ );
1342
1763
  const getHandlerContext = () => ({
1343
1764
  reportData,
1344
1765
  wsClients,
1345
1766
  currentRunIds,
1346
- saveReport,
1767
+ isFilteredRun,
1768
+ saveReport: persistence.saveReport,
1769
+ scheduleReportSave: persistence.scheduleReportSave,
1347
1770
  approvalRouting: routesContext.approvalRouting,
1348
- routesContext
1771
+ routesContext,
1772
+ runController
1349
1773
  });
1350
- const handleRequest = (req) => handleHttpRequest(routesContext, req);
1774
+ const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
1351
1775
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1352
1776
  await loadReport(reportFile, reportData);
1353
- await loadOfflineReports(offlineReportDir, reportData);
1777
+ await loadOfflineReports(reportData, offlineReportDir);
1354
1778
  return {
1355
1779
  port,
1356
1780
  wsClients,
1357
- close: () => {
1358
- },
1781
+ close: createCloseHandler(persistence, runController),
1359
1782
  handleRequest,
1360
1783
  handleWebSocketMessage
1361
1784
  };
1362
1785
  }
1786
+ function createCloseHandler(persistence, runController) {
1787
+ return async () => {
1788
+ await persistence.dispose();
1789
+ runController.dispose();
1790
+ };
1791
+ }
1363
1792
 
1364
1793
  // src/server/bun-adapter.ts
1365
1794
  function logWebSocketError(prefix, error) {
@@ -1557,9 +1986,37 @@ async function startNodeServer(app) {
1557
1986
  console.log(`Crvy Rprtr started at http://localhost:${app.port}`);
1558
1987
  }
1559
1988
 
1989
+ // src/server/shutdown.ts
1990
+ var EXIT_CODES = {
1991
+ SIGINT: 130,
1992
+ SIGTERM: 143,
1993
+ SIGHUP: 129
1994
+ };
1995
+ function createSignalShutdown(deps) {
1996
+ let shuttingDown = false;
1997
+ return async function shutdown(signal) {
1998
+ if (shuttingDown) {
1999
+ deps.exit(EXIT_CODES[signal] ?? 1);
2000
+ return;
2001
+ }
2002
+ shuttingDown = true;
2003
+ try {
2004
+ await deps.close();
2005
+ } catch {
2006
+ }
2007
+ deps.exit(EXIT_CODES[signal] ?? 1);
2008
+ };
2009
+ }
2010
+
1560
2011
  // src/server.ts
1561
2012
  async function startServer(options = {}) {
1562
2013
  const app = await createServerApp(options);
2014
+ const shutdown = createSignalShutdown({ close: () => app.close(), exit: (code) => process.exit(code) });
2015
+ const onSignal = (signal) => {
2016
+ void shutdown(signal);
2017
+ };
2018
+ process.on("SIGINT", onSignal);
2019
+ process.on("SIGTERM", onSignal);
1563
2020
  if (typeof Bun !== "undefined" && typeof Bun.serve === "function") {
1564
2021
  startBunServer(app);
1565
2022
  return;