@alejandrojca/elmulo-reporter 2.0.0-beta.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,713 @@
1
+ const path = require("node:path");
2
+ const PDFDocument = require("pdfkit");
3
+
4
+ const FONT_PATH = path.join(__dirname, "assets", "fonts", "NotoSans-Variable.ttf");
5
+
6
+ const COLORS = {
7
+ ink: "#102D49",
8
+ muted: "#627589",
9
+ teal: "#0CB1B4",
10
+ tealDark: "#056166",
11
+ paper: "#F7FAFD",
12
+ white: "#FFFFFF",
13
+ line: "#D7E2EC",
14
+ stripe: "#EFF5FA",
15
+ green: "#1A9B78",
16
+ red: "#EF4161",
17
+ amber: "#F59E0B",
18
+ orange: "#EA6B18",
19
+ purple: "#7C5CE5",
20
+ blue: "#1E70B8",
21
+ gray: "#8D9FAD",
22
+ };
23
+
24
+ const STATUS_DEFINITIONS = [
25
+ ["passed", "Exitosas", COLORS.green],
26
+ ["failed", "Fallidas", COLORS.red],
27
+ ["environment_error", "Error de ambiente", COLORS.amber],
28
+ ["precondition_error", "Error de precondición", COLORS.orange],
29
+ ["outdated_test", "Pruebas desactualizadas", COLORS.purple],
30
+ ["reported", "Reportadas", COLORS.blue],
31
+ ["omitted", "Omitidas", COLORS.gray],
32
+ ["other", "Otros estados", "#536777"],
33
+ ];
34
+
35
+ const STATUS_LABELS = Object.fromEntries(STATUS_DEFINITIONS.map(([key, label]) => [key, label]));
36
+
37
+ const PDF_SECTIONS = [
38
+ "summary",
39
+ "statusDistribution",
40
+ "runContext",
41
+ "features",
42
+ "issues",
43
+ "previousComparison",
44
+ "recentHistory",
45
+ "flaky",
46
+ "recurrentFailures",
47
+ "slowTests",
48
+ "recommendation",
49
+ ];
50
+
51
+ const DEFAULT_PDF_SECTIONS = PDF_SECTIONS.filter((section) => section !== "slowTests");
52
+
53
+ function effectiveStatus(run, test) {
54
+ const raw = run.annotations?.[test.id]?.status || test.status || "other";
55
+ return ["skipped", "pending"].includes(raw) ? "omitted" : raw;
56
+ }
57
+
58
+ function formatDate(value) {
59
+ if (!value) return "Sin fecha";
60
+ const date = new Date(value);
61
+ if (Number.isNaN(date.getTime())) return String(value);
62
+ return new Intl.DateTimeFormat("es-AR", {
63
+ dateStyle: "medium",
64
+ timeStyle: "short",
65
+ timeZone: "America/Argentina/Buenos_Aires",
66
+ }).format(date);
67
+ }
68
+
69
+ function formatDuration(milliseconds) {
70
+ const value = Math.max(0, Number(milliseconds || 0));
71
+ if (value < 1000) return `${Math.round(value)} ms`;
72
+ if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)} s`;
73
+ const minutes = Math.floor(value / 60_000);
74
+ const seconds = Math.round((value % 60_000) / 1000);
75
+ return `${minutes} min ${seconds} s`;
76
+ }
77
+
78
+ function formatDurationDelta(milliseconds) {
79
+ const value = Number(milliseconds || 0);
80
+ const sign = value > 0 ? "+" : value < 0 ? "−" : "";
81
+ const absolute = Math.abs(value);
82
+ const formatted = absolute >= 1000 && absolute < 60_000 && absolute % 1000 === 0
83
+ ? `${absolute / 1000} s`
84
+ : formatDuration(absolute);
85
+ return `${sign}${formatted}`;
86
+ }
87
+
88
+ function jiraId(test) {
89
+ return (test.tags || [])
90
+ .map((tag) => String(tag).replace(/^@/, "").toUpperCase())
91
+ .find((tag) => /^[A-Z][A-Z0-9]+-\d+$/.test(tag)) || "Sin ticket";
92
+ }
93
+
94
+ function buildStatusSummary(run) {
95
+ const counts = Object.fromEntries(STATUS_DEFINITIONS.map(([key]) => [key, 0]));
96
+ for (const test of run.tests || []) {
97
+ const status = effectiveStatus(run, test);
98
+ counts[Object.hasOwn(counts, status) ? status : "other"] += 1;
99
+ }
100
+ return counts;
101
+ }
102
+
103
+ function buildAssessment(counts, total) {
104
+ const classified = ["environment_error", "precondition_error", "outdated_test", "reported"]
105
+ .reduce((sum, key) => sum + counts[key], 0);
106
+ if (counts.failed > 0) {
107
+ return {
108
+ title: "NO APROBADO",
109
+ color: COLORS.red,
110
+ text: `La corrida conserva ${counts.failed} ${counts.failed === 1 ? "fallo funcional sin clasificar" : "fallos funcionales sin clasificar"}. Se recomienda completar el análisis antes de avanzar.`,
111
+ };
112
+ }
113
+ if (classified > 0 || counts.omitted > 0) {
114
+ return {
115
+ title: "APROBADO CON OBSERVACIONES",
116
+ color: COLORS.amber,
117
+ text: `No quedan fallos funcionales abiertos, pero existen ${classified} resultados clasificados y ${counts.omitted} omitidos que requieren seguimiento.`,
118
+ };
119
+ }
120
+ return {
121
+ title: "APROBADO",
122
+ color: COLORS.green,
123
+ text: `Las ${total} ejecuciones finalizaron sin fallos funcionales ni observaciones abiertas.`,
124
+ };
125
+ }
126
+
127
+ function featureRows(run) {
128
+ const groups = new Map();
129
+ for (const test of run.tests || []) {
130
+ const name = test.feature || test.spec || "Sin Feature";
131
+ const row = groups.get(name) || { name, total: 0, passed: 0, failed: 0, classified: 0 };
132
+ const status = effectiveStatus(run, test);
133
+ row.total += 1;
134
+ if (status === "passed") row.passed += 1;
135
+ else if (status === "failed") row.failed += 1;
136
+ else row.classified += 1;
137
+ groups.set(name, row);
138
+ }
139
+ return [...groups.values()].sort((left, right) => right.failed - left.failed || left.name.localeCompare(right.name));
140
+ }
141
+
142
+ function createDocument(run) {
143
+ const doc = new PDFDocument({
144
+ autoFirstPage: false,
145
+ bufferPages: true,
146
+ compress: true,
147
+ lang: "es-AR",
148
+ pdfVersion: "1.5",
149
+ subset: "PDF/UA",
150
+ tagged: true,
151
+ info: {
152
+ Title: `Informe ejecutivo de calidad — ${run.projectName || "Elmulo Reporter"}`,
153
+ Author: "Elmulo Reporter",
154
+ Subject: `Resultados de la corrida ${run.id || "sin identificador"}`,
155
+ Keywords: "calidad, pruebas, Cypress, Elmulo Reporter",
156
+ Creator: "Elmulo Reporter",
157
+ Producer: "PDFKit",
158
+ CreationDate: new Date(),
159
+ },
160
+ margins: { top: 96, right: 34, bottom: 54, left: 34 },
161
+ size: "A4",
162
+ });
163
+ doc.registerFont("Noto", FONT_PATH);
164
+ doc.registerFont("NotoBold", FONT_PATH);
165
+ doc.font("Noto");
166
+ const root = doc.struct("Document");
167
+ doc.addStructure(root);
168
+ return { doc, root };
169
+ }
170
+
171
+ function collectPdf(doc) {
172
+ return new Promise((resolve, reject) => {
173
+ const chunks = [];
174
+ doc.on("data", (chunk) => chunks.push(chunk));
175
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
176
+ doc.on("error", reject);
177
+ });
178
+ }
179
+
180
+ class Layout {
181
+ constructor(doc, root, run) {
182
+ this.doc = doc;
183
+ this.root = root;
184
+ this.run = run;
185
+ this.section = root;
186
+ this.contentBottom = 784;
187
+ this.pageNumber = 0;
188
+ }
189
+
190
+ addPage(subtitle = "Secciones seleccionadas del informe") {
191
+ this.doc.addPage();
192
+ this.pageNumber += 1;
193
+ this.doc.save().rect(0, 0, this.doc.page.width, this.doc.page.height).fill(COLORS.paper);
194
+ this.doc.rect(0, 0, this.doc.page.width, 8).fill(COLORS.teal).restore();
195
+ this.doc.font("NotoBold").fontSize(9).fillColor(COLORS.teal)
196
+ .text("ELMULO REPORTER", 34, 27, { structParent: this.root, structType: "H2" });
197
+ this.doc.font("NotoBold").fontSize(20).fillColor(COLORS.ink)
198
+ .text("Informe ejecutivo de calidad", 34, 43, { structParent: this.root, structType: "H1" });
199
+ this.doc.font("Noto").fontSize(8.5).fillColor(COLORS.muted)
200
+ .text(subtitle, 34, 70, { structParent: this.root, structType: "P" });
201
+ this.doc.roundedRect(466, 31, 95, 28, 14).fill("#E2F6F5");
202
+ this.doc.font("NotoBold").fontSize(9).fillColor(COLORS.tealDark)
203
+ .text(String(this.run.environment || "N/D").toUpperCase(), 472, 40, {
204
+ align: "center", width: 83, structParent: this.root, structType: "Span",
205
+ });
206
+ this.doc.y = 105;
207
+ }
208
+
209
+ ensure(height, options = {}) {
210
+ if (!this.doc.page || this.doc.y + height > this.contentBottom) {
211
+ this.addPage(options.subtitle);
212
+ return true;
213
+ }
214
+ if (options.avoidResidual && this.contentBottom - (this.doc.y + height) < 55) {
215
+ this.addPage(options.subtitle);
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ startSection(title, subtitle, estimatedHeight = 90) {
222
+ this.ensure(49 + estimatedHeight, { subtitle, avoidResidual: estimatedHeight < 140 });
223
+ this.section = this.doc.struct("Sect");
224
+ this.root.add(this.section);
225
+ this.doc.font("NotoBold").fontSize(14).fillColor(COLORS.ink)
226
+ .text(title, 34, this.doc.y, { structParent: this.section, structType: "H2" });
227
+ this.doc.moveDown(0.25);
228
+ this.doc.font("Noto").fontSize(8).fillColor(COLORS.muted)
229
+ .text(subtitle, 34, this.doc.y, { structParent: this.section, structType: "P" });
230
+ this.doc.moveDown(1);
231
+ }
232
+
233
+ paragraph(text, options = {}) {
234
+ this.doc.font(options.bold ? "NotoBold" : "Noto")
235
+ .fontSize(options.size || 8.5)
236
+ .fillColor(options.color || COLORS.ink)
237
+ .text(String(text ?? ""), options.x || 34, options.y ?? this.doc.y, {
238
+ width: options.width || 527,
239
+ lineGap: options.lineGap ?? 2,
240
+ structParent: options.structParent || this.section,
241
+ structType: options.structType || "P",
242
+ });
243
+ }
244
+
245
+ gap(value = 14) {
246
+ this.doc.y += value;
247
+ }
248
+
249
+ finishFooters() {
250
+ const range = this.doc.bufferedPageRange();
251
+ for (let index = range.start; index < range.start + range.count; index += 1) {
252
+ this.doc.switchToPage(index);
253
+ const previousBottomMargin = this.doc.page.margins.bottom;
254
+ this.doc.page.margins.bottom = 8;
255
+ this.doc.save();
256
+ this.doc.moveTo(34, 807).lineTo(561, 807).strokeColor(COLORS.line).lineWidth(0.7).stroke();
257
+ this.doc.font("Noto").fontSize(7).fillColor(COLORS.muted)
258
+ .text(`Elmulo Reporter V2 — ${this.run.id || "sin identificador"}`, 34, 815, {
259
+ width: 400, lineBreak: false,
260
+ });
261
+ this.doc.font("NotoBold").text(`Página ${index + 1} de ${range.count}`, 475, 815, {
262
+ width: 86, align: "right", lineBreak: false,
263
+ });
264
+ this.doc.restore();
265
+ this.doc.page.margins.bottom = previousBottomMargin;
266
+ }
267
+ }
268
+ }
269
+
270
+ function metricCards(layout, metrics) {
271
+ const { doc, section } = layout;
272
+ const top = doc.y;
273
+ metrics.forEach(([label, value, color], index) => {
274
+ const x = 34 + index * 106;
275
+ doc.roundedRect(x, top, 98, 58, 8).fill(COLORS.white);
276
+ doc.rect(x, top, 4, 58).fill(color);
277
+ doc.font("NotoBold").fontSize(7.5).fillColor(COLORS.muted)
278
+ .text(label, x + 14, top + 12, { width: 76, structParent: section, structType: "Span" });
279
+ doc.font("NotoBold").fontSize(14).fillColor(COLORS.ink)
280
+ .text(String(value), x + 14, top + 30, { width: 76, structParent: section, structType: "Span" });
281
+ });
282
+ doc.y = top + 70;
283
+ }
284
+
285
+ function drawCover(layout, run, counts) {
286
+ const { doc, root } = layout;
287
+ doc.addPage();
288
+ layout.pageNumber += 1;
289
+ doc.rect(0, 0, doc.page.width, doc.page.height).fill(COLORS.paper);
290
+ doc.rect(0, 0, doc.page.width, 14).fill(COLORS.teal);
291
+ doc.font("NotoBold").fontSize(10).fillColor(COLORS.teal)
292
+ .text("ELMULO REPORTER", 50, 82, { structParent: root, structType: "H2" });
293
+ doc.font("NotoBold").fontSize(30).fillColor(COLORS.ink)
294
+ .text("Informe ejecutivo\nde calidad", 50, 126, {
295
+ lineGap: 4, structParent: root, structType: "H1",
296
+ });
297
+ doc.font("Noto").fontSize(11).fillColor(COLORS.muted)
298
+ .text(`${run.projectName || "Proyecto"} — ${formatDate(run.startedAt)}`, 50, 222, {
299
+ width: 480, structParent: root, structType: "P",
300
+ });
301
+ const assessment = buildAssessment(counts, (run.tests || []).length);
302
+ doc.roundedRect(50, 288, 495, 126, 12).fill(COLORS.white);
303
+ doc.rect(50, 288, 8, 126).fill(assessment.color);
304
+ doc.font("NotoBold").fontSize(15).fillColor(assessment.color)
305
+ .text(assessment.title, 78, 315, { structParent: root, structType: "H2" });
306
+ doc.font("Noto").fontSize(10).fillColor(COLORS.ink)
307
+ .text(assessment.text, 78, 349, {
308
+ width: 430, lineGap: 4, structParent: root, structType: "P",
309
+ });
310
+ const metadata = [
311
+ ["Corrida", run.id || "Sin identificador"],
312
+ ["Ambiente", run.environment || "Sin ambiente"],
313
+ ["Duración total", formatDuration(run.durationMs)],
314
+ ["Generado", formatDate(new Date().toISOString())],
315
+ ];
316
+ metadata.forEach(([label, value], index) => {
317
+ const y = 475 + index * 48;
318
+ doc.font("NotoBold").fontSize(8).fillColor(COLORS.muted)
319
+ .text(label, 50, y, { structParent: root, structType: "Span" });
320
+ doc.font("NotoBold").fontSize(10).fillColor(COLORS.ink)
321
+ .text(String(value), 50, y + 16, { width: 495, structParent: root, structType: "P" });
322
+ });
323
+ }
324
+
325
+ function summarySection(layout, run, counts, showAssessment) {
326
+ layout.startSection("Resumen ejecutivo", "Indicadores principales y evaluación general", showAssessment ? 160 : 70);
327
+ const total = (run.tests || []).length;
328
+ const cases = new Set((run.tests || []).map((test) => test.caseId || test.id)).size;
329
+ const passRate = total ? Math.round((counts.passed / total) * 1000) / 10 : 0;
330
+ metricCards(layout, [
331
+ ["Casos", cases, COLORS.blue],
332
+ ["Ejecuciones", total, COLORS.teal],
333
+ ["Exitosas", counts.passed, COLORS.green],
334
+ ["Fallidas", counts.failed, COLORS.red],
335
+ ["Éxito", `${passRate}%`, COLORS.purple],
336
+ ]);
337
+ if (!showAssessment) return;
338
+ const assessment = buildAssessment(counts, total);
339
+ const height = 76;
340
+ layout.doc.roundedRect(34, layout.doc.y, 527, height, 9).fill(COLORS.white);
341
+ layout.doc.rect(34, layout.doc.y, 7, height).fill(assessment.color);
342
+ const top = layout.doc.y;
343
+ layout.doc.font("NotoBold").fontSize(11).fillColor(assessment.color)
344
+ .text(assessment.title, 56, top + 14, { structParent: layout.section, structType: "H3" });
345
+ layout.doc.font("Noto").fontSize(8.3).fillColor(COLORS.ink)
346
+ .text(assessment.text, 56, top + 35, {
347
+ width: 480, lineGap: 2, structParent: layout.section, structType: "P",
348
+ });
349
+ layout.doc.y = top + height + 16;
350
+ }
351
+
352
+ function statusSection(layout, run, counts) {
353
+ layout.startSection("Distribución por estado", "Resultados automáticos y clasificaciones manuales", 120);
354
+ const total = (run.tests || []).length;
355
+ let cursor = 34;
356
+ STATUS_DEFINITIONS.forEach(([key, , color]) => {
357
+ const width = total ? (counts[key] / total) * 527 : 0;
358
+ if (width > 0) layout.doc.rect(cursor, layout.doc.y, width, 18).fill(color);
359
+ cursor += width;
360
+ });
361
+ const top = layout.doc.y + 31;
362
+ STATUS_DEFINITIONS.forEach(([key, label, color], index) => {
363
+ const x = 34 + (index % 4) * 132;
364
+ const y = top + Math.floor(index / 4) * 37;
365
+ layout.doc.circle(x + 4, y + 5, 4).fill(color);
366
+ layout.doc.font("NotoBold").fontSize(7).fillColor(COLORS.ink)
367
+ .text(label, x + 14, y, { width: 110, structParent: layout.section, structType: "Span" });
368
+ layout.doc.font("Noto").fontSize(6.8).fillColor(COLORS.muted)
369
+ .text(`${counts[key]} — ${total ? Math.round((counts[key] / total) * 100) : 0}%`, x + 14, y + 13, {
370
+ width: 110, structParent: layout.section, structType: "Span",
371
+ });
372
+ });
373
+ layout.doc.y = top + 82;
374
+ }
375
+
376
+ function recommendationSection(layout, run, counts) {
377
+ layout.startSection("Recomendación y pendientes", "Decisión ejecutiva y seguimiento necesario", 130);
378
+ const assessment = buildAssessment(counts, (run.tests || []).length);
379
+ const top = layout.doc.y;
380
+ layout.doc.roundedRect(34, top, 527, 82, 9).fill(COLORS.white);
381
+ layout.doc.rect(34, top, 7, 82).fill(assessment.color);
382
+ layout.doc.font("NotoBold").fontSize(11).fillColor(assessment.color)
383
+ .text(assessment.title, 56, top + 15, { structParent: layout.section, structType: "H3" });
384
+ layout.doc.font("Noto").fontSize(8).fillColor(COLORS.ink)
385
+ .text(assessment.text, 56, top + 38, {
386
+ width: 475, lineGap: 2, structParent: layout.section, structType: "P",
387
+ });
388
+ const failed = (run.tests || []).filter((test) => effectiveStatus(run, test) === "failed");
389
+ const missingComments = failed.filter((test) => !run.annotations?.[test.id]?.comment).length;
390
+ const missingTickets = failed.filter((test) => !run.annotations?.[test.id]?.ticket).length;
391
+ layout.doc.font("NotoBold").fontSize(8).fillColor(COLORS.ink)
392
+ .text(`${missingComments} fallos sin comentario`, 50, top + 96, {
393
+ width: 240, structParent: layout.section, structType: "P",
394
+ })
395
+ .text(`${missingTickets} fallos sin ticket`, 315, top + 96, {
396
+ width: 230, structParent: layout.section, structType: "P",
397
+ });
398
+ layout.doc.y = top + 130;
399
+ }
400
+
401
+ function contextSection(layout, run) {
402
+ const fields = [
403
+ ["Corrida", run.id || "Sin identificador"],
404
+ ["Ambiente", run.environment || "Sin ambiente"],
405
+ ["Inicio", formatDate(run.startedAt)],
406
+ ["Duración", formatDuration(run.durationMs)],
407
+ ["Navegador", `${run.browser?.name || "Sin navegador"} ${run.browser?.version || ""}`.trim()],
408
+ ["Cypress", run.cypressVersion || "Sin versión"],
409
+ ["Tags", run.tagExpression || "Sin filtro de tags"],
410
+ ["Rama", run.source?.branch || "Sin rama"],
411
+ ["Commit", run.source?.commit || "Sin commit"],
412
+ ["Pipeline", run.source?.pipelineId || "Sin pipeline"],
413
+ ];
414
+ const columnWidth = 256;
415
+ const columnGap = 15;
416
+ const innerWidth = columnWidth - 24;
417
+ const rows = [];
418
+ for (let index = 0; index < fields.length; index += 2) {
419
+ const row = fields.slice(index, index + 2);
420
+ const valueHeights = row.map(([, value]) => {
421
+ layout.doc.font("Noto").fontSize(8);
422
+ return layout.doc.heightOfString(String(value), { width: innerWidth, lineGap: 1 });
423
+ });
424
+ rows.push({ fields: row, height: Math.max(52, 34 + Math.max(...valueHeights)) });
425
+ }
426
+
427
+ const estimatedHeight = rows.reduce((sum, row) => sum + row.height + 8, 0);
428
+ layout.startSection("Contexto de la corrida", "Información técnica de la ejecución", estimatedHeight);
429
+ for (const row of rows) {
430
+ layout.ensure(row.height + 8, { subtitle: "Continuación del contexto de la corrida" });
431
+ const top = layout.doc.y;
432
+ row.fields.forEach(([label, value], columnIndex) => {
433
+ const x = 34 + columnIndex * (columnWidth + columnGap);
434
+ layout.doc.roundedRect(x, top, columnWidth, row.height, 7).fill(COLORS.white);
435
+ layout.doc.font("NotoBold").fontSize(7.5).fillColor(COLORS.muted)
436
+ .text(label, x + 12, top + 10, {
437
+ width: innerWidth, lineBreak: false, structParent: layout.section, structType: "P",
438
+ });
439
+ layout.doc.font("Noto").fontSize(8).fillColor(COLORS.ink)
440
+ .text(String(value), x + 12, top + 25, {
441
+ width: innerWidth, lineGap: 1, structParent: layout.section, structType: "P",
442
+ });
443
+ });
444
+ layout.doc.y = top + row.height + 8;
445
+ }
446
+ }
447
+
448
+ function splitTextLines(doc, value, width, font = "Noto", size = 7.2) {
449
+ const text = String(value ?? "");
450
+ doc.font(font).fontSize(size);
451
+ const lines = [];
452
+ for (const paragraph of text.split(/\r?\n/)) {
453
+ const words = paragraph.split(/\s+/).filter(Boolean);
454
+ let line = "";
455
+ for (const word of words.length ? words : [""]) {
456
+ let pieces = [word];
457
+ if (doc.widthOfString(word) > width) {
458
+ pieces = [];
459
+ let piece = "";
460
+ for (const character of word) {
461
+ if (piece && doc.widthOfString(piece + character) > width) {
462
+ pieces.push(piece);
463
+ piece = character;
464
+ } else piece += character;
465
+ }
466
+ if (piece) pieces.push(piece);
467
+ }
468
+ for (const piece of pieces) {
469
+ const candidate = line ? `${line} ${piece}` : piece;
470
+ if (line && doc.widthOfString(candidate) > width) {
471
+ lines.push(line);
472
+ line = piece;
473
+ } else line = candidate;
474
+ }
475
+ }
476
+ lines.push(line);
477
+ }
478
+ return lines;
479
+ }
480
+
481
+ function tableSection(layout, options) {
482
+ const rows = options.rows || [];
483
+ if (!rows.length) {
484
+ layout.startSection(options.title, options.subtitle, 68);
485
+ const top = layout.doc.y;
486
+ layout.doc.roundedRect(34, top, 527, 52, 8).fill("#E2F6F5");
487
+ layout.doc.font("NotoBold").fontSize(8.5).fillColor(COLORS.tealDark)
488
+ .text(options.empty, 50, top + 19, {
489
+ width: 495, structParent: layout.section, structType: "P",
490
+ });
491
+ layout.doc.y = top + 68;
492
+ return;
493
+ }
494
+
495
+ let continued = false;
496
+ for (const row of rows) {
497
+ const values = options.values(row).map((value) => String(value ?? ""));
498
+ const widths = options.widths;
499
+ const lineSets = values.map((value, index) =>
500
+ splitTextLines(layout.doc, value, widths[index] - 12, index === 0 ? "NotoBold" : "Noto", 7.1));
501
+ const maxLines = Math.max(...lineSets.map((lines) => lines.length));
502
+ let lineOffset = 0;
503
+ while (lineOffset < maxLines) {
504
+ const availableHeight = layout.doc.page
505
+ ? layout.contentBottom - layout.doc.y - 38
506
+ : 0;
507
+ const maxLinesHere = Math.max(1, Math.floor((availableHeight - 12) / 10));
508
+ const take = Math.min(maxLines - lineOffset, maxLinesHere, 55);
509
+ const rowHeight = Math.max(28, take * 10 + 12);
510
+ if (!layout.doc.page || layout.doc.y + rowHeight + 38 > layout.contentBottom) {
511
+ layout.startSection(
512
+ continued ? `${options.title} — continuación` : options.title,
513
+ options.subtitle,
514
+ Math.min(120, rowHeight + 30),
515
+ );
516
+ continued = true;
517
+ drawTableHeader(layout, options);
518
+ } else if (!continued) {
519
+ layout.startSection(options.title, options.subtitle, Math.min(150, rowHeight + 40));
520
+ drawTableHeader(layout, options);
521
+ continued = true;
522
+ }
523
+ const top = layout.doc.y;
524
+ if ((options._stripeIndex || 0) % 2 === 0) {
525
+ layout.doc.rect(34, top, 527, rowHeight).fill(COLORS.stripe);
526
+ }
527
+ let x = 34;
528
+ lineSets.forEach((lines, index) => {
529
+ const fragment = lines.slice(lineOffset, lineOffset + take).join("\n");
530
+ layout.doc.font(index === 0 ? "NotoBold" : "Noto").fontSize(7.1).fillColor(COLORS.ink)
531
+ .text(fragment, x + 6, top + 6, {
532
+ width: widths[index] - 12,
533
+ lineGap: 1.5,
534
+ structParent: layout.section,
535
+ structType: "TD",
536
+ });
537
+ x += widths[index];
538
+ });
539
+ layout.doc.y = top + rowHeight;
540
+ options._stripeIndex = (options._stripeIndex || 0) + 1;
541
+ lineOffset += take;
542
+ }
543
+ }
544
+ layout.gap(14);
545
+ }
546
+
547
+ function drawTableHeader(layout, options) {
548
+ const top = layout.doc.y;
549
+ layout.doc.roundedRect(34, top, 527, 25, 5).fill(COLORS.ink);
550
+ let x = 34;
551
+ options.headers.forEach((label, index) => {
552
+ layout.doc.font("NotoBold").fontSize(6.8).fillColor(COLORS.white)
553
+ .text(label, x + 6, top + 8, {
554
+ width: options.widths[index] - 12,
555
+ structParent: layout.section,
556
+ structType: "TH",
557
+ });
558
+ x += options.widths[index];
559
+ });
560
+ layout.doc.y = top + 25;
561
+ }
562
+
563
+ function comparisonSection(layout, run) {
564
+ layout.startSection("Comparación con la corrida anterior", "Cambios del mismo ambiente", 95);
565
+ const trends = run.trends?.runs || [];
566
+ const currentIndex = trends.findIndex((item) => item.id === run.id);
567
+ const previous = currentIndex > 0 ? trends[currentIndex - 1] : trends.at(-2);
568
+ const current = trends.find((item) => item.id === run.id) || trends.at(-1);
569
+ if (!previous || !current) {
570
+ const top = layout.doc.y;
571
+ layout.doc.roundedRect(34, top, 527, 58, 8).fill(COLORS.white);
572
+ layout.paragraph("Todavía no hay otra corrida comparable en este ambiente.", {
573
+ x: 50, y: top + 21, width: 495, bold: true,
574
+ });
575
+ layout.doc.y = top + 74;
576
+ return;
577
+ }
578
+ const classified = (item) => ["environment_error", "precondition_error", "outdated_test", "reported"]
579
+ .reduce((sum, key) => sum + Number(item[key] || 0), 0);
580
+ const cards = [
581
+ ["Exitosas", Number(current.passed || 0), Number(previous.passed || 0), COLORS.green, false],
582
+ ["Fallidas", Number(current.failed || 0), Number(previous.failed || 0), COLORS.red, false],
583
+ ["Clasificadas", classified(current), classified(previous), COLORS.amber, false],
584
+ ["Duración", Number(current.duration_ms ?? run.durationMs), Number(previous.duration_ms || 0), COLORS.blue, true],
585
+ ];
586
+ const top = layout.doc.y;
587
+ cards.forEach(([label, value, previousValue, color, isDuration], index) => {
588
+ const x = 34 + index * 132;
589
+ layout.doc.roundedRect(x, top, 122, 78, 8).fill(COLORS.white);
590
+ layout.doc.rect(x, top, 122, 4).fill(color);
591
+ layout.doc.font("NotoBold").fontSize(7).fillColor(COLORS.muted)
592
+ .text(label, x + 11, top + 14, { width: 100, structParent: layout.section, structType: "Span" });
593
+ layout.doc.font("NotoBold").fontSize(11).fillColor(COLORS.ink)
594
+ .text(isDuration ? formatDuration(value) : String(value), x + 11, top + 32, {
595
+ width: 100, structParent: layout.section, structType: "Span",
596
+ });
597
+ const delta = value - previousValue;
598
+ const deltaText = isDuration ? formatDurationDelta(delta) : `${delta > 0 ? "+" : delta < 0 ? "−" : ""}${Math.abs(delta)}`;
599
+ layout.doc.font("Noto").fontSize(6.4).fillColor(COLORS.muted)
600
+ .text(`${deltaText} vs. anterior`, x + 11, top + 56, {
601
+ width: 100, structParent: layout.section, structType: "P",
602
+ });
603
+ });
604
+ layout.doc.y = top + 94;
605
+ }
606
+
607
+ async function buildExecutivePdf(run, options = {}) {
608
+ if (!run || !Array.isArray(run.tests)) {
609
+ throw new Error("La corrida no contiene resultados exportables.");
610
+ }
611
+ const requested = Array.isArray(options.sections) ? options.sections : DEFAULT_PDF_SECTIONS;
612
+ const sections = new Set(requested.filter((section) => PDF_SECTIONS.includes(section)));
613
+ if (!sections.size) throw new Error("Seleccioná al menos una sección para exportar.");
614
+
615
+ const { doc, root } = createDocument(run);
616
+ const result = collectPdf(doc);
617
+ const layout = new Layout(doc, root, run);
618
+ const counts = buildStatusSummary(run);
619
+ drawCover(layout, run, counts);
620
+
621
+ if (sections.has("summary")) summarySection(layout, run, counts, !sections.has("recommendation"));
622
+ if (sections.has("statusDistribution")) statusSection(layout, run, counts);
623
+ if (sections.has("recommendation")) recommendationSection(layout, run, counts);
624
+ if (sections.has("runContext")) contextSection(layout, run);
625
+ if (sections.has("features")) tableSection(layout, {
626
+ title: "Resultados por Feature",
627
+ subtitle: "Totales y estados agrupados por Feature",
628
+ rows: featureRows(run),
629
+ empty: "No hay Features disponibles en esta corrida.",
630
+ headers: ["Feature", "Total", "Exitosas", "Fallidas", "Clasificadas"],
631
+ widths: [305, 48, 57, 57, 60],
632
+ values: (row) => [row.name, row.total, row.passed, row.failed, row.classified],
633
+ });
634
+ if (sections.has("issues")) tableSection(layout, {
635
+ title: "Problemas que requieren seguimiento",
636
+ subtitle: "Fallos y clasificaciones de la corrida actual",
637
+ rows: (run.tests || []).filter((test) => effectiveStatus(run, test) !== "passed"),
638
+ empty: "No se detectaron problemas abiertos.",
639
+ headers: ["Prueba", "Jira", "Estado", "Duración"],
640
+ widths: [295, 85, 95, 52],
641
+ values: (test) => [
642
+ test.originalTitle || test.title || "Prueba sin nombre",
643
+ jiraId(test),
644
+ STATUS_LABELS[effectiveStatus(run, test)] || "Otro estado",
645
+ formatDuration(test.durationMs),
646
+ ],
647
+ });
648
+ if (sections.has("previousComparison")) comparisonSection(layout, run);
649
+ if (sections.has("recentHistory")) tableSection(layout, {
650
+ title: "Historial reciente",
651
+ subtitle: "Últimas ejecuciones del mismo ambiente",
652
+ rows: (run.trends?.runs || []).slice(-12).reverse(),
653
+ empty: "No hay ejecuciones históricas disponibles.",
654
+ headers: ["Fecha", "Total", "Exitosas", "Fallidas", "% éxito"],
655
+ widths: [290, 52, 62, 62, 61],
656
+ values: (row) => {
657
+ const total = Number(row.total || 0);
658
+ return [
659
+ formatDate(row.started_at), total, Number(row.passed || 0), Number(row.failed || 0),
660
+ `${total ? Math.round((Number(row.passed || 0) / total) * 100) : 0}%`,
661
+ ];
662
+ },
663
+ });
664
+ if (sections.has("flaky")) tableSection(layout, {
665
+ title: "Pruebas inestables",
666
+ subtitle: "Casos que pasaron luego de uno o más reintentos",
667
+ rows: (run.tests || []).filter((test) => test.flaky),
668
+ empty: "No se detectaron pruebas inestables en la corrida actual.",
669
+ headers: ["Prueba", "Detalle"],
670
+ widths: [320, 207],
671
+ values: (test) => [
672
+ test.originalTitle || test.title || "Prueba sin nombre",
673
+ `${test.retries || 0} reintentos — ${test.spec || "Sin especificación"}`,
674
+ ],
675
+ });
676
+ if (sections.has("recurrentFailures")) tableSection(layout, {
677
+ title: "Fallos recurrentes",
678
+ subtitle: "Pruebas con fallos repetidos en el historial",
679
+ rows: run.analytics?.recurrentFailures || [],
680
+ empty: "No se detectaron fallos recurrentes en el historial disponible.",
681
+ headers: ["Prueba", "Detalle"],
682
+ widths: [320, 207],
683
+ values: (test) => [
684
+ test.title || test.originalTitle || "Prueba sin nombre",
685
+ `${test.failures || test.failed || 0} fallos de ${test.executions || 0} ejecuciones — Jira ${test.jira_id || "sin ticket"}`,
686
+ ],
687
+ });
688
+ if (sections.has("slowTests")) tableSection(layout, {
689
+ title: "Pruebas más lentas",
690
+ subtitle: "Ranking histórico por duración",
691
+ rows: run.analytics?.slowest || [],
692
+ empty: "No hay información suficiente para calcular pruebas lentas.",
693
+ headers: ["Prueba", "Detalle"],
694
+ widths: [320, 207],
695
+ values: (test) => [
696
+ test.title || test.originalTitle || "Prueba sin nombre",
697
+ `Promedio ${formatDuration(test.average_duration || test.duration_ms || test.durationMs)} — ${test.spec || "Sin especificación"}`,
698
+ ],
699
+ });
700
+
701
+ layout.finishFooters();
702
+ doc.end();
703
+ return result;
704
+ }
705
+
706
+ module.exports = {
707
+ STATUS_DEFINITIONS,
708
+ PDF_SECTIONS,
709
+ DEFAULT_PDF_SECTIONS,
710
+ buildExecutivePdf,
711
+ buildStatusSummary,
712
+ formatDurationDelta,
713
+ };