@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.
- package/CHANGELOG.md +60 -0
- package/README.md +9 -0
- package/dist/{chunk-BRFA7JCQ.js → chunk-IH44LYNM.js} +138 -15
- package/dist/{chunk-IEYNAB6M.js → chunk-MWLM3HWS.js} +66 -16
- package/dist/cli.js +2 -2
- package/dist/index.css +52 -0
- package/dist/index.js +154 -83
- package/dist/report-state.d.ts.map +1 -1
- package/dist/report-utils.d.ts +3 -1
- package/dist/report-utils.d.ts.map +1 -1
- package/dist/reporter-utils.d.ts +11 -1
- package/dist/reporter-utils.d.ts.map +1 -1
- package/dist/reporter.cjs +773 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +74 -20
- package/dist/schemas.d.ts +103 -8
- package/dist/schemas.d.ts.map +1 -1
- package/dist/server/app.d.ts +1 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/report-watch.d.ts +9 -0
- package/dist/server/report-watch.d.ts.map +1 -0
- package/dist/server.cjs +1176 -0
- package/dist/server.js +2 -2
- package/dist/types.d.ts +6 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -4
|
@@ -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
|
+
});
|