@crvy/rprtr 0.0.3 → 0.0.5

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.
@@ -0,0 +1,773 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/reporter.ts
31
+ var reporter_exports = {};
32
+ __export(reporter_exports, {
33
+ CrvyRprtr: () => CrvyRprtr,
34
+ default: () => reporter_default
35
+ });
36
+ module.exports = __toCommonJS(reporter_exports);
37
+ var import_promises2 = require("fs/promises");
38
+ var import_path2 = require("path");
39
+ var import_p_limit = __toESM(require("p-limit"), 1);
40
+
41
+ // src/report-artifact.ts
42
+ var import_promises = require("fs/promises");
43
+ var import_path = require("path");
44
+ var import_url = require("url");
45
+
46
+ // src/report-utils.ts
47
+ function normalizeScreenshotsBaseUrl(baseUrl) {
48
+ return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
49
+ }
50
+ function classifyImage(image) {
51
+ if (image.actual !== void 0 || image.diff !== void 0) {
52
+ return "comparison";
53
+ }
54
+ if (image.expect !== void 0) {
55
+ return "baseline-only";
56
+ }
57
+ return "declared-only";
58
+ }
59
+ function withImageSource(image) {
60
+ return {
61
+ ...image,
62
+ source: classifyImage(image)
63
+ };
64
+ }
65
+ function mergeDeclaredImages(images, visualNames) {
66
+ const names = /* @__PURE__ */ new Set([...Object.keys(images), ...visualNames]);
67
+ return Object.fromEntries(
68
+ Array.from(names, (name) => {
69
+ const current = images[name] ?? {};
70
+ return [name, withImageSource(current)];
71
+ })
72
+ );
73
+ }
74
+ function attachmentsToImages(attachments, screenshotsBaseUrl = "/screenshots/") {
75
+ const images = {};
76
+ const baseUrl = normalizeScreenshotsBaseUrl(screenshotsBaseUrl);
77
+ for (const attachment of attachments) {
78
+ if (attachment.contentType !== "image/png") continue;
79
+ const match = attachment.name.match(/^(.+?)-(actual|expected|diff)(?:\.png)?$/);
80
+ if (match === null) continue;
81
+ const baseName = match[1];
82
+ const role = match[2];
83
+ if (baseName === null || baseName === void 0 || role === null || role === void 0) continue;
84
+ images[baseName] ??= {};
85
+ const url = `${baseUrl}${attachment.path}`;
86
+ const img = images[baseName];
87
+ if (img !== null && img !== void 0) {
88
+ if (role === "actual") img.actual = url;
89
+ else if (role === "expected") img.expect = url;
90
+ else if (role === "diff") img.diff = url;
91
+ }
92
+ }
93
+ for (const key of Object.keys(images)) {
94
+ const img = images[key];
95
+ if (img?.actual !== void 0 && img?.expect !== void 0 && img?.diff === void 0) {
96
+ delete img.expect;
97
+ }
98
+ }
99
+ return mergeDeclaredImages(images, []);
100
+ }
101
+ function mapStatus(status) {
102
+ switch (status) {
103
+ case "passed":
104
+ return "success";
105
+ case "failed":
106
+ return "failed";
107
+ case "skipped":
108
+ return "pending";
109
+ default:
110
+ return "unknown";
111
+ }
112
+ }
113
+
114
+ // src/report-state.ts
115
+ function isCurrentArtifact(image) {
116
+ return image?.actual !== void 0 || image?.expect !== void 0 || image?.diff !== void 0;
117
+ }
118
+ function isReusablePassingImage(image) {
119
+ const source = image.source ?? classifyImage(image);
120
+ if (source === "comparison") {
121
+ return image.actual !== void 0 && image.diff === void 0;
122
+ }
123
+ return source === "baseline-only";
124
+ }
125
+ function hasReusablePassingImages(images) {
126
+ return Object.values(images).some((img) => img !== null && img !== void 0 && isReusablePassingImage(img));
127
+ }
128
+ function preservePreviousPassingImages(test, status, images) {
129
+ if (status !== "passed") {
130
+ return images;
131
+ }
132
+ const previousImages = test.results?.[0]?.images ?? {};
133
+ if (!hasReusablePassingImages(previousImages)) {
134
+ return images;
135
+ }
136
+ return Object.entries(previousImages).reduce((currentImages, [name, previousImage]) => {
137
+ if (!Object.hasOwn(currentImages, name) || previousImage === void 0 || !isReusablePassingImage(previousImage) || isCurrentArtifact(currentImages[name])) {
138
+ return currentImages;
139
+ }
140
+ return {
141
+ ...currentImages,
142
+ [name]: {
143
+ ...previousImage,
144
+ source: previousImage.source ?? classifyImage(previousImage)
145
+ }
146
+ };
147
+ }, images);
148
+ }
149
+ function countDiffImages(images) {
150
+ return Object.values(images).filter((img) => img?.diff !== null && img?.diff !== void 0).length;
151
+ }
152
+ function createMutableReportState(screenshotDir = "./screenshots") {
153
+ return {
154
+ reportData: {
155
+ isRunning: false,
156
+ tests: {},
157
+ browsers: ["chromium"],
158
+ isUpdateMode: false,
159
+ screenshotDir
160
+ },
161
+ currentRunIds: /* @__PURE__ */ new Set()
162
+ };
163
+ }
164
+ function applyTestBeginEvent(state, data) {
165
+ const { id, title, titlePath, browser, location } = data;
166
+ state.currentRunIds.add(id);
167
+ state.reportData.tests[id] ??= {
168
+ id,
169
+ titlePath: titlePath ?? [],
170
+ browser: browser ?? "",
171
+ title: title ?? "",
172
+ location,
173
+ status: "running"
174
+ };
175
+ return state.reportData.tests[id];
176
+ }
177
+ function applyTestEndEvent(state, data, options = {}) {
178
+ const test = state.reportData.tests[data.id];
179
+ if (test === void 0) {
180
+ return null;
181
+ }
182
+ test.status = mapStatus(data.status);
183
+ const resultStatus = data.status === "passed" ? "success" : data.status === "failed" ? "failed" : "pending";
184
+ const images = preservePreviousPassingImages(
185
+ test,
186
+ data.status,
187
+ mergeDeclaredImages(attachmentsToImages(data.attachments, options.screenshotsBaseUrl), data.visualNames)
188
+ );
189
+ const diffCount = countDiffImages(images);
190
+ const hasDiffs = diffCount > 0;
191
+ if (hasDiffs) {
192
+ test.approved = null;
193
+ }
194
+ test.results = [
195
+ {
196
+ status: resultStatus,
197
+ retries: 0,
198
+ images,
199
+ error: data.error,
200
+ duration: data.duration
201
+ }
202
+ ];
203
+ return {
204
+ test,
205
+ diffCount
206
+ };
207
+ }
208
+ function finalizeRunEvent(state) {
209
+ state.reportData.isRunning = false;
210
+ state.reportData.tests = Object.fromEntries(
211
+ Object.entries(state.reportData.tests).filter(([id]) => state.currentRunIds.has(id))
212
+ );
213
+ state.currentRunIds.clear();
214
+ const runTests = Object.values(state.reportData.tests).filter((test) => test !== void 0);
215
+ return {
216
+ passed: runTests.filter((test) => test.status === "success").length,
217
+ failed: runTests.filter((test) => test.status === "failed").length,
218
+ pending: runTests.filter((test) => test.status === "pending").length
219
+ };
220
+ }
221
+
222
+ // src/schemas.ts
223
+ var import_zod = require("zod");
224
+ var LocationSchema = import_zod.z.object({
225
+ file: import_zod.z.string(),
226
+ line: import_zod.z.number()
227
+ });
228
+ var VisualSourceSchema = import_zod.z.enum(["comparison", "baseline-only", "declared-only"]);
229
+ var ImagesSchema = import_zod.z.object({
230
+ actual: import_zod.z.string().optional(),
231
+ expect: import_zod.z.string().optional(),
232
+ diff: import_zod.z.string().optional(),
233
+ error: import_zod.z.string().optional(),
234
+ source: VisualSourceSchema.optional()
235
+ });
236
+ var AttachmentSchema = import_zod.z.object({
237
+ name: import_zod.z.string(),
238
+ path: import_zod.z.string(),
239
+ contentType: import_zod.z.string()
240
+ });
241
+ var TestStatusSchema = import_zod.z.enum(["unknown", "pending", "running", "failed", "approved", "success", "retrying"]);
242
+ var TestResultStatusSchema = import_zod.z.enum(["failed", "success", "pending"]);
243
+ var TestResultSchema = import_zod.z.object({
244
+ status: TestResultStatusSchema,
245
+ retries: import_zod.z.number(),
246
+ images: import_zod.z.record(import_zod.z.string(), ImagesSchema).optional(),
247
+ error: import_zod.z.string().optional(),
248
+ duration: import_zod.z.number().optional()
249
+ });
250
+ var TestDataSchema = import_zod.z.object({
251
+ id: import_zod.z.string(),
252
+ titlePath: import_zod.z.array(import_zod.z.string()),
253
+ browser: import_zod.z.string(),
254
+ title: import_zod.z.string(),
255
+ skip: import_zod.z.union([import_zod.z.boolean(), import_zod.z.string()]).optional(),
256
+ retries: import_zod.z.number().optional(),
257
+ status: TestStatusSchema.optional(),
258
+ results: import_zod.z.array(TestResultSchema).optional(),
259
+ approved: import_zod.z.record(import_zod.z.string(), import_zod.z.number()).nullable().optional(),
260
+ attachments: import_zod.z.array(AttachmentSchema).optional(),
261
+ location: LocationSchema.optional()
262
+ });
263
+ var CrvyRprtrTestSchema = TestDataSchema.extend({
264
+ checked: import_zod.z.boolean()
265
+ });
266
+ var CrvyRprtrSuiteSchema = import_zod.z.lazy(
267
+ () => import_zod.z.object({
268
+ path: import_zod.z.array(import_zod.z.string()),
269
+ skip: import_zod.z.boolean(),
270
+ status: TestStatusSchema.optional(),
271
+ opened: import_zod.z.boolean(),
272
+ checked: import_zod.z.boolean(),
273
+ indeterminate: import_zod.z.boolean(),
274
+ children: import_zod.z.record(import_zod.z.string(), import_zod.z.union([CrvyRprtrSuiteSchema, CrvyRprtrTestSchema])).optional()
275
+ })
276
+ );
277
+ var WebSocketMessageSchema = import_zod.z.object({
278
+ type: import_zod.z.enum(["test-begin", "test-end", "run-end", "approve", "sync"]),
279
+ data: import_zod.z.unknown()
280
+ });
281
+ var TestBeginDataSchema = import_zod.z.object({
282
+ id: import_zod.z.string(),
283
+ title: import_zod.z.string(),
284
+ titlePath: import_zod.z.array(import_zod.z.string()),
285
+ browser: import_zod.z.string(),
286
+ location: LocationSchema
287
+ });
288
+ var TestEndDataSchema = import_zod.z.object({
289
+ id: import_zod.z.string(),
290
+ status: import_zod.z.enum(["passed", "failed", "skipped"]),
291
+ attachments: import_zod.z.array(AttachmentSchema),
292
+ visualNames: import_zod.z.array(import_zod.z.string()).default([]),
293
+ error: import_zod.z.string().optional(),
294
+ duration: import_zod.z.number().optional()
295
+ });
296
+ var ReportDataSchema = import_zod.z.object({
297
+ isRunning: import_zod.z.boolean(),
298
+ tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
299
+ browsers: import_zod.z.array(import_zod.z.string()),
300
+ isUpdateMode: import_zod.z.boolean(),
301
+ screenshotDir: import_zod.z.string()
302
+ });
303
+ var LoadedReportDataSchema = import_zod.z.object({
304
+ tests: import_zod.z.record(import_zod.z.string(), TestDataSchema).optional(),
305
+ isUpdateMode: import_zod.z.boolean().optional()
306
+ });
307
+ var OfflineEventSchema = import_zod.z.object({
308
+ type: import_zod.z.enum(["test-begin", "test-end", "run-end"]),
309
+ data: import_zod.z.unknown(),
310
+ timestamp: import_zod.z.number(),
311
+ workerIndex: import_zod.z.number()
312
+ });
313
+ var OfflineReportSchema = import_zod.z.object({
314
+ version: import_zod.z.number(),
315
+ generatedAt: import_zod.z.string(),
316
+ workers: import_zod.z.number(),
317
+ events: import_zod.z.array(OfflineEventSchema)
318
+ });
319
+ var ApproveRequestBodySchema = import_zod.z.object({
320
+ id: import_zod.z.string(),
321
+ retry: import_zod.z.number(),
322
+ image: import_zod.z.string()
323
+ });
324
+ var ReportApiResponseSchema = import_zod.z.object({
325
+ tests: import_zod.z.record(import_zod.z.string(), TestDataSchema),
326
+ isUpdateMode: import_zod.z.boolean().optional()
327
+ });
328
+ var ClientBootstrapDataSchema = import_zod.z.object({
329
+ report: ReportApiResponseSchema.extend({
330
+ isUpdateMode: import_zod.z.boolean()
331
+ }),
332
+ liveUpdates: import_zod.z.boolean(),
333
+ approvalEnabled: import_zod.z.boolean(),
334
+ approvalMessage: import_zod.z.string().optional()
335
+ });
336
+ var ImagesViewModeSchema = import_zod.z.enum(["side-by-side", "swap", "slide", "blend"]);
337
+ function safeParse(schema, data) {
338
+ const result = schema.safeParse(data);
339
+ if (result.success) {
340
+ return result.data;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ // src/report-artifact.ts
346
+ var import_meta = { url: require("url").pathToFileURL(__filename).href };
347
+ var DEFAULT_REPORT_HTML_PATH = "./crvy-rprtr.html";
348
+ var STATIC_ARTIFACT_APPROVAL_MESSAGE = "This artifact is read-only. Open it with the Crvy Rprtr server to approve screenshots.";
349
+ function toBrowserPath(pathValue) {
350
+ const normalized = pathValue.split(import_path.sep).join("/");
351
+ if (normalized === "") return ".";
352
+ if (normalized.startsWith(".")) return normalized;
353
+ return `./${normalized}`;
354
+ }
355
+ function toBrowserDirPath(pathValue) {
356
+ const path = toBrowserPath(pathValue);
357
+ return path === "." ? "./" : `${path}/`;
358
+ }
359
+ function serializeBootstrapData(data) {
360
+ return JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
361
+ }
362
+ function escapeInlineStyle(style) {
363
+ return style.replace(/<\/style>/gi, "<\\/style>");
364
+ }
365
+ function escapeInlineScript(script) {
366
+ return script.replace(/<\/script>/gi, "<\\/script>");
367
+ }
368
+ function renderArtifactHtml(template, bootstrapData, stylesheet, script) {
369
+ const stylesheetPlaceholder = '<link rel="stylesheet" href="/dist/index.css" />';
370
+ const scriptPlaceholder = '<script type="module" src="/dist/index.js"></script>';
371
+ if (!template.includes(stylesheetPlaceholder) || !template.includes(scriptPlaceholder)) {
372
+ throw new Error("Failed to find static asset placeholders in the HTML template");
373
+ }
374
+ const withStylesheet = template.replace(
375
+ stylesheetPlaceholder,
376
+ () => `<style>${escapeInlineStyle(stylesheet)}</style>`
377
+ );
378
+ const bootstrapScript = `<script id="crvy-rprtr-bootstrap" type="application/json">${serializeBootstrapData(bootstrapData)}</script>`;
379
+ return withStylesheet.replace(
380
+ scriptPlaceholder,
381
+ () => `${bootstrapScript}
382
+ <script type="module">${escapeInlineScript(script)}</script>`
383
+ );
384
+ }
385
+ async function getPackagedAssetPath(fileName) {
386
+ const currentDir = (0, import_path.dirname)((0, import_url.fileURLToPath)(import_meta.url));
387
+ const candidates = [currentDir, (0, import_path.resolve)(currentDir, "../dist")];
388
+ const resolvedCandidates = await Promise.all(
389
+ candidates.map(async (candidate) => {
390
+ const assetPath = (0, import_path.resolve)(candidate, fileName);
391
+ try {
392
+ await (0, import_promises.access)(assetPath);
393
+ return assetPath;
394
+ } catch {
395
+ return null;
396
+ }
397
+ })
398
+ );
399
+ const resolvedAssetPath = resolvedCandidates.find((assetPath) => assetPath !== null);
400
+ if (resolvedAssetPath !== void 0) {
401
+ return resolvedAssetPath;
402
+ }
403
+ throw new Error(
404
+ `Could not find packaged asset "${fileName}". Run "bun run build" before generating report artifacts.`
405
+ );
406
+ }
407
+ function buildStaticBootstrapData(events, screenshotDir, htmlPath) {
408
+ const screenshotBaseUrl = toBrowserDirPath((0, import_path.relative)((0, import_path.dirname)(htmlPath), (0, import_path.resolve)(screenshotDir)));
409
+ const state = createMutableReportState(screenshotDir);
410
+ for (const event of events) {
411
+ switch (event.type) {
412
+ case "test-begin": {
413
+ const parsed = safeParse(TestBeginDataSchema, event.data);
414
+ if (parsed !== null) {
415
+ applyTestBeginEvent(state, parsed);
416
+ }
417
+ break;
418
+ }
419
+ case "test-end": {
420
+ const parsed = safeParse(TestEndDataSchema, event.data);
421
+ if (parsed !== null) {
422
+ applyTestEndEvent(state, parsed, { screenshotsBaseUrl: screenshotBaseUrl });
423
+ }
424
+ break;
425
+ }
426
+ case "run-end":
427
+ finalizeRunEvent(state);
428
+ break;
429
+ }
430
+ }
431
+ const bootstrapData = {
432
+ report: {
433
+ tests: state.reportData.tests,
434
+ isUpdateMode: state.reportData.isUpdateMode
435
+ },
436
+ liveUpdates: false,
437
+ approvalEnabled: false,
438
+ approvalMessage: STATIC_ARTIFACT_APPROVAL_MESSAGE
439
+ };
440
+ const parsedBootstrapData = safeParse(ClientBootstrapDataSchema, bootstrapData);
441
+ if (parsedBootstrapData === null) {
442
+ throw new Error("Failed to build static report bootstrap data");
443
+ }
444
+ return parsedBootstrapData;
445
+ }
446
+ async function writeReportArtifact(options) {
447
+ const reportHtmlPath = (0, import_path.resolve)(options.reportHtmlPath ?? DEFAULT_REPORT_HTML_PATH);
448
+ const [indexHtmlPath, indexJsPath, indexCssPath] = await Promise.all([
449
+ getPackagedAssetPath("index.html"),
450
+ getPackagedAssetPath("index.js"),
451
+ getPackagedAssetPath("index.css")
452
+ ]);
453
+ await (0, import_promises.mkdir)((0, import_path.dirname)(reportHtmlPath), { recursive: true });
454
+ const [template, script, stylesheet] = await Promise.all([
455
+ (0, import_promises.readFile)(indexHtmlPath, "utf8"),
456
+ (0, import_promises.readFile)(indexJsPath, "utf8"),
457
+ (0, import_promises.readFile)(indexCssPath, "utf8")
458
+ ]);
459
+ const bootstrapData = buildStaticBootstrapData(options.events, options.screenshotDir, reportHtmlPath);
460
+ const html = renderArtifactHtml(template, bootstrapData, stylesheet, script);
461
+ await (0, import_promises.writeFile)(reportHtmlPath, html);
462
+ }
463
+
464
+ // src/reporter-utils.ts
465
+ var NAMED_SCREENSHOT_STEP_TITLE = /toHaveScreenshot\((.+?)\)/;
466
+ var UNNAMED_SCREENSHOT_STEP_TITLE = /^Expect "toHaveScreenshot"(?:\s|$)/;
467
+ var SYNTHETIC_SCREENSHOT_PREFIX = "__unnamed-screenshot-";
468
+ function normalizeNamedScreenshot(titleMatch) {
469
+ const unquotedName = titleMatch.trim().replace(/^['"`]|['"`]$/g, "");
470
+ if (unquotedName === "") {
471
+ return null;
472
+ }
473
+ const normalizedPath = unquotedName.replace(/\\/g, "/");
474
+ const normalizedName = normalizedPath.replace(/\.png$/, "");
475
+ if (normalizedName === "") {
476
+ return null;
477
+ }
478
+ return {
479
+ visualName: normalizedName,
480
+ snapshotBaseName: normalizedName
481
+ };
482
+ }
483
+ function appendDeclaration(state, declaration) {
484
+ if (state.seenVisualNames.has(declaration.visualName)) {
485
+ return;
486
+ }
487
+ state.seenVisualNames.add(declaration.visualName);
488
+ state.declarations.push(declaration);
489
+ }
490
+ function visitStep(step, state) {
491
+ const declarationsBeforeChildren = state.declarations.length;
492
+ for (const nestedStep of step.steps) {
493
+ visitStep(nestedStep, state);
494
+ }
495
+ const namedMatch = step.title.match(NAMED_SCREENSHOT_STEP_TITLE);
496
+ if (namedMatch?.[1] !== void 0) {
497
+ const declaration = normalizeNamedScreenshot(namedMatch[1]);
498
+ if (declaration !== null) {
499
+ appendDeclaration(state, declaration);
500
+ return true;
501
+ }
502
+ }
503
+ const hasNestedScreenshotDeclaration = state.declarations.length > declarationsBeforeChildren;
504
+ if (UNNAMED_SCREENSHOT_STEP_TITLE.test(step.title) && !hasNestedScreenshotDeclaration) {
505
+ appendDeclaration(state, {
506
+ visualName: `${SYNTHETIC_SCREENSHOT_PREFIX}${state.nextUnnamedIndex}`
507
+ });
508
+ state.nextUnnamedIndex += 1;
509
+ return true;
510
+ }
511
+ return hasNestedScreenshotDeclaration;
512
+ }
513
+ function extractScreenshotDeclarations(steps) {
514
+ const state = {
515
+ declarations: [],
516
+ seenVisualNames: /* @__PURE__ */ new Set(),
517
+ nextUnnamedIndex: 1
518
+ };
519
+ for (const step of steps) {
520
+ visitStep(step, state);
521
+ }
522
+ return state.declarations;
523
+ }
524
+
525
+ // src/reporter.ts
526
+ var MAX_CONCURRENT_FILE_OPS = 5;
527
+ var CrvyRprtr = class {
528
+ ws = null;
529
+ serverUrl;
530
+ screenshotDir;
531
+ queue = [];
532
+ workerIndex;
533
+ offlineReportPath;
534
+ reportHtmlPath;
535
+ isOfflineMode = false;
536
+ hadOfflineMode = false;
537
+ runEvents = [];
538
+ constructor(options = {}) {
539
+ this.serverUrl = options.serverUrl ?? "ws://localhost:3000";
540
+ this.screenshotDir = options.screenshotDir ?? "./screenshots";
541
+ this.workerIndex = parseInt(process.env.TEST_WORKER_INDEX ?? "0", 10) || 0;
542
+ this.offlineReportPath = options.offlineReportPath ?? `./crvy-rprtr-${this.workerIndex}.json`;
543
+ this.reportHtmlPath = options.reportHtmlPath ?? "./crvy-rprtr.html";
544
+ }
545
+ async onBegin(config, suite) {
546
+ console.log(`[CrvyRprtr] Starting run with ${suite.allTests().length} tests`);
547
+ await (0, import_promises2.mkdir)(this.screenshotDir, { recursive: true });
548
+ this.connect();
549
+ }
550
+ connect() {
551
+ const WebSocketConstructor = globalThis.WebSocket;
552
+ if (typeof WebSocketConstructor !== "function") {
553
+ console.log("[CrvyRprtr] WebSocket unavailable in current runtime; offline mode enabled");
554
+ this.enableOfflineMode();
555
+ return;
556
+ }
557
+ try {
558
+ this.ws = new WebSocketConstructor(this.serverUrl);
559
+ this.ws.onopen = () => {
560
+ console.log("[CrvyRprtr] Connected to Crvy Rprtr server");
561
+ this.isOfflineMode = false;
562
+ for (const msg of this.queue) this.ws.send(msg);
563
+ this.queue = [];
564
+ };
565
+ this.ws.onerror = (error) => {
566
+ console.error("[CrvyRprtr] WebSocket error:", error);
567
+ this.enableOfflineMode();
568
+ };
569
+ this.ws.onclose = () => {
570
+ console.log("[CrvyRprtr] Disconnected from Crvy Rprtr server");
571
+ this.enableOfflineMode();
572
+ };
573
+ } catch (e) {
574
+ console.error("[CrvyRprtr] Failed to connect:", e);
575
+ this.enableOfflineMode();
576
+ }
577
+ }
578
+ enableOfflineMode() {
579
+ if (!this.isOfflineMode) {
580
+ this.isOfflineMode = true;
581
+ this.hadOfflineMode = true;
582
+ console.log("[CrvyRprtr] Offline mode enabled - events will be queued to file");
583
+ }
584
+ }
585
+ onTestBegin(test) {
586
+ const titlePath = [];
587
+ let suite = test.parent;
588
+ while (suite && suite.type === "describe") {
589
+ titlePath.unshift(suite.title);
590
+ suite = suite.parent;
591
+ }
592
+ this.send({
593
+ type: "test-begin",
594
+ data: {
595
+ id: test.id,
596
+ title: test.title,
597
+ titlePath,
598
+ browser: test.parent.project()?.name ?? "chromium",
599
+ location: {
600
+ file: test.location.file,
601
+ line: test.location.line
602
+ }
603
+ }
604
+ });
605
+ }
606
+ async onTestEnd(test, result) {
607
+ const screenshotDeclarations = extractScreenshotDeclarations(result.steps);
608
+ const visualNames = screenshotDeclarations.map(({ visualName }) => visualName);
609
+ const savedAttachments = await this.saveAttachments(test.id, result);
610
+ await this.copySnapshotBaselines(test, result.status, screenshotDeclarations, savedAttachments);
611
+ this.send({
612
+ type: "test-end",
613
+ data: {
614
+ id: test.id,
615
+ title: test.title,
616
+ status: result.status,
617
+ attachments: savedAttachments,
618
+ visualNames,
619
+ error: result.errors.length > 0 ? result.errors[0]?.message : void 0,
620
+ duration: result.duration
621
+ }
622
+ });
623
+ }
624
+ async copySnapshotBaselines(test, status, screenshotDeclarations, savedAttachments) {
625
+ if (status !== "passed") return;
626
+ const namedDeclarations = screenshotDeclarations.filter(
627
+ (declaration) => declaration.snapshotBaseName !== void 0
628
+ );
629
+ if (namedDeclarations.length === 0) return;
630
+ const projectName = test.parent.project()?.name;
631
+ const snapshotDir = `${test.location.file}-snapshots`;
632
+ const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(test.id));
633
+ const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
634
+ const copyPromises = namedDeclarations.map(
635
+ ({ visualName, snapshotBaseName }) => limit(async () => {
636
+ const destName = `${visualName}-expected.png`;
637
+ const destPath = (0, import_path2.join)(testScreenshotDir, destName);
638
+ const snapshotPath = projectName === "" ? (0, import_path2.join)(snapshotDir, `${snapshotBaseName}-${process.platform}.png`) : (0, import_path2.join)(snapshotDir, `${snapshotBaseName}-${projectName ?? "chromium"}-${process.platform}.png`);
639
+ try {
640
+ await (0, import_promises2.mkdir)((0, import_path2.dirname)(destPath), { recursive: true });
641
+ await (0, import_promises2.copyFile)(snapshotPath, destPath);
642
+ savedAttachments.push({
643
+ name: destName,
644
+ path: `${this.sanitizeId(test.id)}/${destName}`,
645
+ contentType: "image/png"
646
+ });
647
+ console.log(`[CrvyRprtr] Attached baseline: ${snapshotPath}`);
648
+ } catch {
649
+ }
650
+ })
651
+ );
652
+ await Promise.all(copyPromises);
653
+ }
654
+ async saveAttachments(testId, result) {
655
+ const savedAttachments = [];
656
+ const testScreenshotDir = (0, import_path2.join)(this.screenshotDir, this.sanitizeId(testId));
657
+ const limit = (0, import_p_limit.default)(MAX_CONCURRENT_FILE_OPS);
658
+ const attachmentPromises = result.attachments.filter(
659
+ (attachment) => attachment.contentType === "image/png" && attachment.path !== void 0
660
+ ).map(
661
+ (attachment) => limit(async () => {
662
+ try {
663
+ await (0, import_promises2.mkdir)(testScreenshotDir, { recursive: true });
664
+ const fileName = attachment.name;
665
+ const destPath = (0, import_path2.join)(testScreenshotDir, fileName);
666
+ await (0, import_promises2.copyFile)(attachment.path, destPath);
667
+ const attachmentData = {
668
+ name: attachment.name,
669
+ path: `${this.sanitizeId(testId)}/${fileName}`,
670
+ contentType: attachment.contentType
671
+ };
672
+ savedAttachments.push(attachmentData);
673
+ console.log(`[CrvyRprtr] Saved screenshot: ${destPath}`);
674
+ } catch (e) {
675
+ console.error(`[CrvyRprtr] Failed to save screenshot: ${attachment.path}`, e);
676
+ const fallbackData = {
677
+ name: attachment.name,
678
+ path: attachment.path,
679
+ contentType: attachment.contentType
680
+ };
681
+ savedAttachments.push(fallbackData);
682
+ }
683
+ })
684
+ );
685
+ await Promise.all(attachmentPromises);
686
+ return savedAttachments;
687
+ }
688
+ sanitizeId(id) {
689
+ return id.replace(/[^a-zA-Z0-9-_]/g, "_");
690
+ }
691
+ async writeOfflineReport() {
692
+ if (this.runEvents.length === 0) {
693
+ console.log("[CrvyRprtr] No offline events to write");
694
+ return;
695
+ }
696
+ try {
697
+ const report = {
698
+ version: 1,
699
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
700
+ workers: this.workerIndex + 1,
701
+ events: this.runEvents.map((e) => ({
702
+ ...e,
703
+ timestamp: Date.now(),
704
+ workerIndex: this.workerIndex
705
+ }))
706
+ };
707
+ await (0, import_promises2.writeFile)(this.offlineReportPath, JSON.stringify(report, null, 2));
708
+ console.log(`[CrvyRprtr] Wrote offline report: ${this.offlineReportPath}`);
709
+ } catch (e) {
710
+ console.error("[CrvyRprtr] Failed to write offline report:", e);
711
+ }
712
+ }
713
+ async writeStaticArtifact() {
714
+ try {
715
+ await writeReportArtifact({
716
+ events: this.runEvents,
717
+ screenshotDir: this.screenshotDir,
718
+ reportHtmlPath: this.reportHtmlPath
719
+ });
720
+ console.log(`[CrvyRprtr] Wrote report artifact: ${this.reportHtmlPath}`);
721
+ } catch (e) {
722
+ console.error("[CrvyRprtr] Failed to write report artifact:", e);
723
+ }
724
+ }
725
+ async onEnd(result) {
726
+ this.send({
727
+ type: "run-end",
728
+ data: {
729
+ status: result.status
730
+ }
731
+ });
732
+ await this.writeStaticArtifact();
733
+ if (this.hadOfflineMode) {
734
+ await this.writeOfflineReport();
735
+ }
736
+ await new Promise((resolve2) => {
737
+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
738
+ resolve2();
739
+ return;
740
+ }
741
+ this.ws.onclose = () => {
742
+ resolve2();
743
+ };
744
+ setTimeout(() => {
745
+ this.ws?.close();
746
+ resolve2();
747
+ }, 1e3);
748
+ this.ws.close();
749
+ });
750
+ }
751
+ send(msg) {
752
+ const msgObj = msg;
753
+ if (msgObj.type === "test-begin" || msgObj.type === "test-end" || msgObj.type === "run-end") {
754
+ this.runEvents.push({
755
+ type: msgObj.type,
756
+ data: msgObj.data
757
+ });
758
+ }
759
+ const payload = JSON.stringify(msg);
760
+ if (!this.isOfflineMode) {
761
+ if (this.ws?.readyState === WebSocket.OPEN) {
762
+ this.ws.send(payload);
763
+ } else {
764
+ this.queue.push(payload);
765
+ }
766
+ }
767
+ }
768
+ };
769
+ var reporter_default = CrvyRprtr;
770
+ // Annotate the CommonJS export names for ESM import in node:
771
+ 0 && (module.exports = {
772
+ CrvyRprtr
773
+ });