@crvy/rprtr 0.1.4 → 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 (41) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +8 -7
  3. package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
  4. package/dist/{chunk-KLGJSEKV.js → chunk-RFZGJSL3.js} +439 -127
  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 +297 -76
  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 +6 -0
  24. package/dist/server/app.d.ts.map +1 -1
  25. package/dist/server/handlers.d.ts +3 -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/routes-context.d.ts +13 -0
  30. package/dist/server/routes-context.d.ts.map +1 -0
  31. package/dist/server/routes.d.ts +6 -1
  32. package/dist/server/routes.d.ts.map +1 -1
  33. package/dist/server/run-controller.d.ts +85 -0
  34. package/dist/server/run-controller.d.ts.map +1 -0
  35. package/dist/server/run-routes.d.ts +3 -0
  36. package/dist/server/run-routes.d.ts.map +1 -0
  37. package/dist/server.cjs +633 -273
  38. package/dist/server.js +2 -2
  39. package/dist/types.d.ts +6 -0
  40. package/dist/types.d.ts.map +1 -1
  41. 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")
279
293
  })
280
294
  ]);
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()
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()
285
309
  });
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({
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()
322
+ })
323
+ ]);
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()
328
+ });
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()
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()
403
455
  });
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()
407
- });
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
 
@@ -914,8 +996,8 @@ function handleTestEnd(ctx, data) {
914
996
  broadcastToBrowsers(ctx.wsClients, message);
915
997
  }
916
998
  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);
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 });
919
1001
  await ctx.saveReport();
920
1002
  console.log(`
921
1003
  Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
@@ -968,16 +1050,93 @@ function handleRegister(ctx, data) {
968
1050
  ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
969
1051
  }
970
1052
  }
1053
+ if (data.configFile !== void 0 && data.cwd !== void 0) {
1054
+ ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
1055
+ }
971
1056
  console.log("[Server] Reporter registered with config:", {
972
1057
  playwrightSnapshotDir: data.playwrightSnapshotDir,
973
1058
  playwrightTestDir: data.playwrightTestDir
974
1059
  });
975
1060
  }
976
1061
 
977
- // src/server/routes.ts
1062
+ // src/server/playwright-config.ts
978
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
+
1103
+ // src/server/routes.ts
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
979
1138
  async function handleRoot(ctx) {
980
- const html = await respondWithFile((0, import_path6.join)(ctx.staticDir, "index.html"), "text/html");
1139
+ const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
981
1140
  return html ?? new Response("Not Found", { status: 404 });
982
1141
  }
983
1142
  async function handleAppCss() {
@@ -992,12 +1151,15 @@ async function handleSrcFiles(req) {
992
1151
  return file ?? new Response("Not Found", { status: 404 });
993
1152
  }
994
1153
  function handleApiReport(ctx) {
995
- return Response.json(ctx.reportData);
1154
+ return Response.json({
1155
+ ...ctx.reportData,
1156
+ runEnabled: ctx.runContext !== void 0
1157
+ });
996
1158
  }
997
1159
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
998
1160
  function actualPathFromUrl(ctx, actualUrl) {
999
1161
  if (actualUrl.startsWith("/screenshots/")) {
1000
- return (0, import_path6.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1162
+ return (0, import_path7.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1001
1163
  }
1002
1164
  if (actualUrl.startsWith("/file/")) {
1003
1165
  return decodeURIComponent(actualUrl.slice("/file/".length));
@@ -1119,12 +1281,12 @@ async function handleScreenshots(ctx, req) {
1119
1281
  }
1120
1282
  async function handleDist(ctx, req) {
1121
1283
  const path = new URL(req.url).pathname.slice("/dist/".length);
1122
- const filePath = (0, import_path6.join)(ctx.staticDir, path);
1284
+ const filePath = (0, import_path7.join)(ctx.staticDir, path);
1123
1285
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
1124
1286
  const file = await respondWithFile(filePath, contentType);
1125
1287
  return file ?? new Response("Not Found", { status: 404 });
1126
1288
  }
1127
- function handleHttpRequest(ctx, req) {
1289
+ function handleHttpRequest(ctx, req, runController) {
1128
1290
  const pathname = new URL(req.url).pathname;
1129
1291
  if (pathname === "/") {
1130
1292
  return handleRoot(ctx);
@@ -1144,6 +1306,10 @@ function handleHttpRequest(ctx, req) {
1144
1306
  if (pathname === "/api/approve-all" && req.method === "POST") {
1145
1307
  return handleApiApproveAll(ctx);
1146
1308
  }
1309
+ const runResponse = handleRunRoutes(pathname, req.method, runController, req);
1310
+ if (runResponse !== null) {
1311
+ return runResponse;
1312
+ }
1147
1313
  if (pathname.startsWith("/api/images/")) {
1148
1314
  return handleApiImages(req);
1149
1315
  }
@@ -1160,9 +1326,222 @@ function handleHttpRequest(ctx, req) {
1160
1326
  return Promise.resolve(new Response("Not Found", { status: 404 }));
1161
1327
  }
1162
1328
 
1163
- // 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");
1164
1337
  var import_meta = { url: require("url").pathToFileURL(__filename).href };
1165
- 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 = {};
1166
1545
  function createReportData(options) {
1167
1546
  return {
1168
1547
  isRunning: false,
@@ -1188,37 +1567,6 @@ async function loadReport(reportPath, reportData) {
1188
1567
  console.log("No report.json found, using empty state");
1189
1568
  }
1190
1569
  }
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
1570
  async function handleParsedWebSocketMessage(ctx, msg) {
1223
1571
  switch (msg.type) {
1224
1572
  case "test-begin": {
@@ -1282,19 +1630,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1282
1630
  };
1283
1631
  }
1284
1632
  async function resolveStaticDir(staticDir) {
1285
- const currentDir = (0, import_path7.dirname)((0, import_url.fileURLToPath)(import_meta.url));
1633
+ const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta2.url));
1286
1634
  const candidates = staticDir === void 0 ? [
1287
1635
  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")];
1636
+ (0, import_path8.join)(currentDir, "dist"),
1637
+ (0, import_path8.join)(currentDir, "..", "dist"),
1638
+ (0, import_path8.join)(currentDir, "..", "..", "dist"),
1639
+ (0, import_path8.join)(currentDir, ".."),
1640
+ (0, import_path8.join)(currentDir, "..", "..")
1641
+ ] : [staticDir, (0, import_path8.join)(staticDir, "dist")];
1294
1642
  const resolvedCandidates = await Promise.all(
1295
1643
  candidates.map(async (candidate) => ({
1296
1644
  candidate,
1297
- exists: await fileExists((0, import_path7.join)(candidate, "index.html"))
1645
+ exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
1298
1646
  }))
1299
1647
  );
1300
1648
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1305,27 +1653,33 @@ async function resolveStaticDir(staticDir) {
1305
1653
  }
1306
1654
  async function resolveReportPath(reportPath) {
1307
1655
  if (await isDirectory(reportPath)) {
1308
- return { reportFile: (0, import_path7.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1656
+ return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1309
1657
  }
1310
- return { reportFile: reportPath, offlineReportDir: (0, import_path7.dirname)(reportPath) };
1658
+ return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
1311
1659
  }
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,
1319
- 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
- };
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
+ });
1329
1683
  }
1330
1684
  async function createServerApp(options = {}) {
1331
1685
  const port = options.port ?? 3e3;
@@ -1335,26 +1689,32 @@ async function createServerApp(options = {}) {
1335
1689
  const staticDir = await resolveStaticDir(options.staticDir);
1336
1690
  const wsClients = /* @__PURE__ */ new Set();
1337
1691
  const currentRunIds = /* @__PURE__ */ new Set();
1338
- async function saveReport() {
1339
- await writeJsonFile(reportFile, reportData);
1340
- }
1692
+ const saveReport = () => writeJsonFile(reportFile, reportData);
1341
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
+ });
1342
1699
  const getHandlerContext = () => ({
1343
1700
  reportData,
1344
1701
  wsClients,
1345
1702
  currentRunIds,
1703
+ isFilteredRun,
1346
1704
  saveReport,
1347
1705
  approvalRouting: routesContext.approvalRouting,
1348
- routesContext
1706
+ routesContext,
1707
+ runController
1349
1708
  });
1350
- const handleRequest = (req) => handleHttpRequest(routesContext, req);
1709
+ const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
1351
1710
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1352
1711
  await loadReport(reportFile, reportData);
1353
- await loadOfflineReports(offlineReportDir, reportData);
1712
+ await loadOfflineReports(reportData, offlineReportDir);
1354
1713
  return {
1355
1714
  port,
1356
1715
  wsClients,
1357
1716
  close: () => {
1717
+ runController.dispose();
1358
1718
  },
1359
1719
  handleRequest,
1360
1720
  handleWebSocketMessage