@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.
package/finalize.cjs ADDED
@@ -0,0 +1,982 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const initSqlJs = require("sql.js");
4
+ const {
5
+ atomicWriteFile,
6
+ ensureDir,
7
+ stableId,
8
+ writeJson,
9
+ } = require("./core.cjs");
10
+ const { normalizeActor, sanitizeText } = require("./security.cjs");
11
+
12
+ const DATABASE_SCHEMA_VERSION = 3;
13
+
14
+ const VALID_ANNOTATION_STATUSES = new Set([
15
+ "passed",
16
+ "failed",
17
+ "skipped",
18
+ "pending",
19
+ "environment_error",
20
+ "precondition_error",
21
+ "outdated_test",
22
+ "reported",
23
+ ]);
24
+
25
+ function extractJiraId(tags = []) {
26
+ for (const tag of tags) {
27
+ const match = String(tag || "").trim().match(/^@?([A-Z][A-Z0-9]+-\d+)$/i);
28
+ if (match) return match[1].toUpperCase();
29
+ }
30
+ return "";
31
+ }
32
+
33
+ function readJson(filePath, fallback = null) {
34
+ if (!fs.existsSync(filePath)) return fallback;
35
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
36
+ }
37
+
38
+ function normalizeTitle(value) {
39
+ return String(value || "")
40
+ .normalize("NFKD")
41
+ .replace(/[\u0300-\u036f]/g, "")
42
+ .replace(/\s+\(example\s+#\d+\)\s*$/i, "")
43
+ .replace(/\s+/g, " ")
44
+ .trim()
45
+ .toLowerCase();
46
+ }
47
+
48
+ function canonicalTestIdentity(test = {}) {
49
+ const jiraId = String(test.jira_id || test.jiraId || "")
50
+ .replace(/^@/, "")
51
+ .trim()
52
+ .toUpperCase();
53
+ if (/^[A-Z][A-Z0-9]+-\d+$/.test(jiraId)) {
54
+ return `jira:${jiraId}`;
55
+ }
56
+
57
+ const caseId = String(test.case_id || test.caseId || "").trim();
58
+ if (caseId) return `case:${caseId}`;
59
+
60
+ const logicalId = String(test.logical_id || test.logicalId || "").trim();
61
+ if (logicalId) return `logical:${logicalId}`;
62
+
63
+ return `fallback:${stableId(
64
+ String(test.spec || "").replaceAll("\\", "/").toLowerCase(),
65
+ normalizeTitle(test.originalTitle || test.title),
66
+ )}`;
67
+ }
68
+
69
+ function readCucumberScenarios(projectRoot) {
70
+ const cucumberPath = path.join(projectRoot, "jsonlogs", "cucumber.json");
71
+ const features = readJson(cucumberPath, []);
72
+ const scenarios = [];
73
+
74
+ for (const feature of Array.isArray(features) ? features : []) {
75
+ for (const scenario of feature.elements || []) {
76
+ scenarios.push({
77
+ feature: feature.name || "",
78
+ uri: feature.uri || "",
79
+ name: scenario.name || "",
80
+ originalName: scenario.original_name || scenario.name || "",
81
+ isExample: Boolean(scenario.is_example),
82
+ exampleIndex: Number(scenario.example_index || 0) || null,
83
+ status: scenario.status || "",
84
+ tags: (scenario.tags || []).map((tag) => tag.name).filter(Boolean),
85
+ steps: (scenario.steps || []).map((step, index) => ({
86
+ index,
87
+ keyword: String(step.keyword || "Step").trim(),
88
+ name: step.name || "",
89
+ status: String(step.result?.status || "unknown").toLowerCase(),
90
+ durationMs: Math.round(Number(step.result?.duration || 0) / 1_000_000),
91
+ startedAt: step.started_at || null,
92
+ finishedAt: step.finished_at || null,
93
+ error: step.result?.error_message || "",
94
+ })),
95
+ });
96
+ }
97
+ }
98
+
99
+ return scenarios;
100
+ }
101
+
102
+ function mergeCucumber(run, scenarios) {
103
+ const byName = new Map();
104
+ const usageByName = new Map();
105
+ for (const scenario of scenarios) {
106
+ const key = normalizeTitle(scenario.name);
107
+ if (!byName.has(key)) byName.set(key, []);
108
+ byName.get(key).push(scenario);
109
+ }
110
+
111
+ for (const test of run.tests) {
112
+ const key = normalizeTitle(test.title);
113
+ const candidates = byName.get(key) || [];
114
+ const usage = usageByName.get(key) || 0;
115
+ const scenario =
116
+ candidates.find(
117
+ (candidate, index) =>
118
+ index >= usage &&
119
+ (!candidate.uri ||
120
+ test.spec.includes(
121
+ candidate.uri.replace(/^.*?cypress\//, "cypress/"),
122
+ )),
123
+ ) ||
124
+ candidates[usage] ||
125
+ candidates[0];
126
+
127
+ if (!scenario) continue;
128
+ usageByName.set(key, usage + 1);
129
+ test.feature = scenario.feature;
130
+ test.tags = scenario.tags;
131
+ test.originalTitle = scenario.originalName;
132
+ test.isExample = scenario.isExample;
133
+ test.exampleIndex = scenario.exampleIndex;
134
+ test.steps = scenario.steps;
135
+
136
+ const attemptStartedAt = test.attempts?.at(-1)?.startedAt;
137
+ let estimatedTimestamp = Date.parse(attemptStartedAt || "");
138
+ if (!Number.isFinite(estimatedTimestamp)) {
139
+ estimatedTimestamp = Date.parse(run.startedAt || "");
140
+ }
141
+ if (!Number.isFinite(estimatedTimestamp)) {
142
+ estimatedTimestamp = 0;
143
+ }
144
+
145
+ test.steps = test.steps.map((step) => {
146
+ const startedAt = step.startedAt || (
147
+ estimatedTimestamp > 0 ? new Date(estimatedTimestamp).toISOString() : null
148
+ );
149
+ const durationMs = Math.max(0, Number(step.durationMs || 0));
150
+ const finishedAt = step.finishedAt || (
151
+ startedAt ? new Date(Date.parse(startedAt) + durationMs).toISOString() : null
152
+ );
153
+ const parsedFinishedAt = Date.parse(finishedAt || "");
154
+ if (Number.isFinite(parsedFinishedAt)) {
155
+ estimatedTimestamp = parsedFinishedAt;
156
+ } else {
157
+ estimatedTimestamp += durationMs;
158
+ }
159
+
160
+ return {
161
+ ...step,
162
+ startedAt,
163
+ finishedAt,
164
+ };
165
+ });
166
+ }
167
+
168
+ return run;
169
+ }
170
+
171
+ function enrichRunTests(run) {
172
+ for (const test of run.tests || []) {
173
+ const exampleMatch = String(test.title || "").match(
174
+ /^(.*?)\s+\(example\s+#(\d+)\)\s*$/i,
175
+ );
176
+ test.originalTitle =
177
+ test.originalTitle ||
178
+ (exampleMatch ? exampleMatch[1].trim() : test.title);
179
+ test.isExample = Boolean(test.isExample || exampleMatch);
180
+ test.exampleIndex =
181
+ Number(test.exampleIndex || exampleMatch?.[2] || 0) || null;
182
+ const jiraId = extractJiraId(test.tags);
183
+ test.caseId = jiraId
184
+ ? stableId("jira", jiraId)
185
+ : stableId("case", test.spec, test.suite, test.originalTitle);
186
+ test.logicalId = test.caseId;
187
+ test.executionId = stableId(
188
+ test.logicalId,
189
+ test.exampleIndex ? `example:${test.exampleIndex}` : test.title,
190
+ );
191
+
192
+ const timestamps = [
193
+ ...(test.logs || []).map((log) => Date.parse(log.timestamp || "")),
194
+ ...(test.steps || []).flatMap((step) => [
195
+ Date.parse(step.startedAt || ""),
196
+ Date.parse(step.finishedAt || ""),
197
+ ]),
198
+ ].filter((timestamp) => Number.isFinite(timestamp));
199
+
200
+ if (timestamps.length >= 2) {
201
+ const timelineDurationMs = Math.max(...timestamps) - Math.min(...timestamps);
202
+ test.timelineDurationMs = Math.max(0, timelineDurationMs);
203
+ test.durationMs = test.timelineDurationMs;
204
+ } else {
205
+ test.timelineDurationMs = Number(test.durationMs || 0);
206
+ }
207
+ }
208
+
209
+ const caseIds = new Set((run.tests || []).map((test) => test.caseId));
210
+ run.counts.testCases = caseIds.size;
211
+ run.counts.executions = (run.tests || []).length;
212
+ return run;
213
+ }
214
+
215
+ function copyEvidence(projectRoot, runDir, run) {
216
+ const mediaDir = path.join(runDir, "media");
217
+ ensureDir(mediaDir);
218
+ const copied = new Map();
219
+
220
+ function copyCandidate(candidate, type) {
221
+ if (!candidate?.absolutePath || !fs.existsSync(candidate.absolutePath)) {
222
+ return candidate ? { ...candidate, available: false } : null;
223
+ }
224
+
225
+ const cacheKey = candidate.absolutePath;
226
+ if (copied.has(cacheKey)) return copied.get(cacheKey);
227
+
228
+ const extension = path.extname(candidate.absolutePath);
229
+ const filename = `${stableId(cacheKey).slice(0, 12)}-${path.basename(
230
+ candidate.absolutePath,
231
+ extension,
232
+ )
233
+ .replace(/[^\w.-]+/g, "-")
234
+ .slice(0, 80)}${extension}`;
235
+ const destination = path.join(mediaDir, filename);
236
+ fs.copyFileSync(candidate.absolutePath, destination);
237
+ const normalized = {
238
+ ...candidate,
239
+ type,
240
+ available: true,
241
+ reportPath: `../runs/${run.id}/media/${filename}`,
242
+ };
243
+ copied.set(cacheKey, normalized);
244
+ return normalized;
245
+ }
246
+
247
+ for (const spec of run.specs || []) {
248
+ spec.video = copyCandidate(spec.video, "video");
249
+ spec.screenshots = (spec.screenshots || []).map((screenshot) =>
250
+ copyCandidate(screenshot, "screenshot"),
251
+ );
252
+ }
253
+
254
+ for (const test of run.tests || []) {
255
+ for (const attempt of test.attempts || []) {
256
+ attempt.video = copyCandidate(attempt.video, "video");
257
+ attempt.screenshots = (attempt.screenshots || []).map((screenshot) =>
258
+ copyCandidate(screenshot, "screenshot"),
259
+ );
260
+ }
261
+ test.attachments = (test.attachments || []).map((attachment) => ({
262
+ ...attachment,
263
+ available: fs.existsSync(path.join(runDir, attachment.path)),
264
+ reportPath: `../runs/${run.id}/${attachment.path.replaceAll("\\", "/")}`,
265
+ }));
266
+ }
267
+
268
+ run.attachments = (run.attachments || []).map((attachment) => ({
269
+ ...attachment,
270
+ available: fs.existsSync(path.join(runDir, attachment.path)),
271
+ reportPath: `../runs/${run.id}/${attachment.path.replaceAll("\\", "/")}`,
272
+ }));
273
+
274
+ return run;
275
+ }
276
+
277
+ async function openDatabase(databasePath) {
278
+ const SQL = await initSqlJs({
279
+ locateFile(file) {
280
+ return require.resolve(`sql.js/dist/${file}`);
281
+ },
282
+ });
283
+ const bytes = fs.existsSync(databasePath)
284
+ ? fs.readFileSync(databasePath)
285
+ : undefined;
286
+ const database = bytes ? new SQL.Database(bytes) : new SQL.Database();
287
+
288
+ database.run(`
289
+ CREATE TABLE IF NOT EXISTS runs (
290
+ id TEXT PRIMARY KEY,
291
+ environment TEXT NOT NULL,
292
+ tag_expression TEXT NOT NULL,
293
+ status TEXT NOT NULL,
294
+ started_at TEXT NOT NULL,
295
+ ended_at TEXT NOT NULL,
296
+ duration_ms INTEGER NOT NULL,
297
+ total INTEGER NOT NULL,
298
+ passed INTEGER NOT NULL,
299
+ failed INTEGER NOT NULL,
300
+ skipped INTEGER NOT NULL,
301
+ pending INTEGER NOT NULL,
302
+ flaky INTEGER NOT NULL
303
+ );
304
+ `);
305
+ const runColumns = new Set(
306
+ queryRows(database, "PRAGMA table_info(runs)").map((column) => column.name),
307
+ );
308
+ if (!runColumns.has("lifecycle")) {
309
+ database.run("ALTER TABLE runs ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'completed';");
310
+ }
311
+ if (!runColumns.has("metadata_json")) {
312
+ database.run("ALTER TABLE runs ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';");
313
+ }
314
+ database.run(`
315
+ CREATE TABLE IF NOT EXISTS tests (
316
+ run_id TEXT NOT NULL,
317
+ test_id TEXT NOT NULL,
318
+ spec TEXT NOT NULL,
319
+ title TEXT NOT NULL,
320
+ status TEXT NOT NULL,
321
+ duration_ms INTEGER NOT NULL,
322
+ retries INTEGER NOT NULL,
323
+ flaky INTEGER NOT NULL,
324
+ jira_id TEXT NOT NULL DEFAULT '',
325
+ tags_json TEXT NOT NULL DEFAULT '[]',
326
+ http_json TEXT NOT NULL DEFAULT '[]',
327
+ PRIMARY KEY (run_id, test_id)
328
+ );
329
+ `);
330
+ const testColumns = new Set(
331
+ queryRows(database, "PRAGMA table_info(tests)").map((column) => column.name),
332
+ );
333
+ if (!testColumns.has("jira_id")) {
334
+ database.run("ALTER TABLE tests ADD COLUMN jira_id TEXT NOT NULL DEFAULT '';");
335
+ }
336
+ if (!testColumns.has("tags_json")) {
337
+ database.run("ALTER TABLE tests ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]';");
338
+ }
339
+ if (!testColumns.has("http_json")) {
340
+ database.run("ALTER TABLE tests ADD COLUMN http_json TEXT NOT NULL DEFAULT '[]';");
341
+ }
342
+ if (!testColumns.has("logical_id")) {
343
+ database.run("ALTER TABLE tests ADD COLUMN logical_id TEXT NOT NULL DEFAULT '';");
344
+ }
345
+ if (!testColumns.has("case_id")) {
346
+ database.run("ALTER TABLE tests ADD COLUMN case_id TEXT NOT NULL DEFAULT '';");
347
+ }
348
+ database.run(
349
+ "CREATE INDEX IF NOT EXISTS tests_identity_idx ON tests(test_id, run_id);",
350
+ );
351
+ database.run(
352
+ "CREATE INDEX IF NOT EXISTS tests_jira_idx ON tests(jira_id, run_id);",
353
+ );
354
+ database.run(`
355
+ CREATE TABLE IF NOT EXISTS test_annotations (
356
+ run_id TEXT NOT NULL,
357
+ test_id TEXT NOT NULL,
358
+ status TEXT NOT NULL,
359
+ comment TEXT NOT NULL DEFAULT '',
360
+ ticket TEXT NOT NULL DEFAULT '',
361
+ updated_at TEXT NOT NULL,
362
+ PRIMARY KEY (run_id, test_id)
363
+ );
364
+ `);
365
+ const annotationColumns = new Set(
366
+ queryRows(database, "PRAGMA table_info(test_annotations)")
367
+ .map((column) => column.name),
368
+ );
369
+ if (!annotationColumns.has("actor")) {
370
+ database.run("ALTER TABLE test_annotations ADD COLUMN actor TEXT NOT NULL DEFAULT 'Usuario local';");
371
+ }
372
+ if (!annotationColumns.has("revision")) {
373
+ database.run("ALTER TABLE test_annotations ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;");
374
+ }
375
+ database.run(`
376
+ CREATE TABLE IF NOT EXISTS annotation_events (
377
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
378
+ run_id TEXT NOT NULL,
379
+ test_id TEXT NOT NULL,
380
+ previous_status TEXT NOT NULL,
381
+ status TEXT NOT NULL,
382
+ comment TEXT NOT NULL DEFAULT '',
383
+ ticket TEXT NOT NULL DEFAULT '',
384
+ actor TEXT NOT NULL,
385
+ source TEXT NOT NULL DEFAULT 'manual',
386
+ created_at TEXT NOT NULL
387
+ );
388
+ `);
389
+ database.run(`
390
+ CREATE TABLE IF NOT EXISTS schema_meta (
391
+ key TEXT PRIMARY KEY,
392
+ value TEXT NOT NULL
393
+ );
394
+ `);
395
+ database.run(
396
+ "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?)",
397
+ [String(DATABASE_SCHEMA_VERSION)],
398
+ );
399
+ database.run(
400
+ "CREATE INDEX IF NOT EXISTS annotations_status_idx ON test_annotations(status, run_id);",
401
+ );
402
+ database.run(
403
+ "UPDATE test_annotations SET status = 'reported' WHERE status = 'fix_in_progress';",
404
+ );
405
+
406
+ return database;
407
+ }
408
+
409
+ function persistRun(database, run) {
410
+ const counts = run.counts;
411
+ database.run(
412
+ `INSERT OR REPLACE INTO runs (
413
+ id, environment, tag_expression, status, started_at, ended_at,
414
+ duration_ms, total, passed, failed, skipped, pending, flaky,
415
+ lifecycle, metadata_json
416
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
417
+ [
418
+ run.id,
419
+ run.environment,
420
+ run.tagExpression,
421
+ run.status,
422
+ run.startedAt,
423
+ run.endedAt,
424
+ run.durationMs,
425
+ counts.total,
426
+ counts.passed,
427
+ counts.failed,
428
+ counts.skipped,
429
+ counts.pending,
430
+ counts.flaky,
431
+ run.lifecycle || "completed",
432
+ JSON.stringify({
433
+ browser: run.browser || {},
434
+ cypressVersion: run.cypressVersion || "",
435
+ system: run.system || {},
436
+ source: run.source || {},
437
+ execution: run.execution || null,
438
+ reporterVersion: "2.0.0-beta.5",
439
+ }),
440
+ ],
441
+ );
442
+
443
+ const statement = database.prepare(
444
+ `INSERT OR REPLACE INTO tests (
445
+ run_id, test_id, spec, title, status, duration_ms, retries, flaky,
446
+ jira_id, tags_json, logical_id, case_id, http_json
447
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
448
+ );
449
+ for (const test of run.tests) {
450
+ statement.run([
451
+ run.id,
452
+ test.id,
453
+ test.spec,
454
+ test.title,
455
+ test.status,
456
+ test.durationMs,
457
+ test.retries,
458
+ test.flaky ? 1 : 0,
459
+ extractJiraId(test.tags),
460
+ JSON.stringify(test.tags || []),
461
+ test.logicalId || test.id,
462
+ test.caseId || test.logicalId || test.id,
463
+ JSON.stringify(test.http || []),
464
+ ]);
465
+ }
466
+ statement.free();
467
+ }
468
+
469
+ function backfillTestMetadata(database, outputDir) {
470
+ const runsDir = path.join(outputDir, "runs");
471
+ if (!fs.existsSync(runsDir)) return;
472
+
473
+ for (const entry of fs.readdirSync(runsDir, { withFileTypes: true })) {
474
+ if (!entry.isDirectory()) continue;
475
+ const runPath = path.join(runsDir, entry.name, "run.json");
476
+ const historicalRun = readJson(runPath);
477
+ if (!historicalRun) continue;
478
+
479
+ for (const test of historicalRun.tests || []) {
480
+ const tags = test.tags || [];
481
+ const [storedMetadata] = queryRows(
482
+ database,
483
+ `SELECT jira_id
484
+ FROM tests
485
+ WHERE run_id = ? AND test_id = ?`,
486
+ [historicalRun.id, test.id],
487
+ );
488
+ const jiraId = extractJiraId(tags) || storedMetadata?.jira_id || "";
489
+ const originalTitle =
490
+ test.originalTitle ||
491
+ String(test.title || "").replace(/\s+\(example\s+#\d+\)\s*$/i, "").trim();
492
+ const caseId = jiraId
493
+ ? stableId("jira", jiraId)
494
+ : stableId("case", test.spec, test.suite, originalTitle);
495
+ database.run(
496
+ `UPDATE tests
497
+ SET jira_id = ?, tags_json = ?, logical_id = ?, case_id = ?
498
+ WHERE run_id = ? AND test_id = ?`,
499
+ [
500
+ jiraId,
501
+ JSON.stringify(tags),
502
+ caseId,
503
+ caseId,
504
+ historicalRun.id,
505
+ test.id,
506
+ ],
507
+ );
508
+ }
509
+ }
510
+ }
511
+
512
+ function queryRows(database, sql, parameters = []) {
513
+ const statement = database.prepare(sql);
514
+ statement.bind(parameters);
515
+ const rows = [];
516
+ while (statement.step()) rows.push(statement.getAsObject());
517
+ statement.free();
518
+ return rows;
519
+ }
520
+
521
+ function loadAnnotations(database, runId) {
522
+ const rows = queryRows(
523
+ database,
524
+ `SELECT test_id, status, comment, ticket, updated_at, actor, revision
525
+ FROM test_annotations
526
+ WHERE run_id = ?`,
527
+ [runId],
528
+ );
529
+
530
+ return Object.fromEntries(rows.map((row) => [
531
+ row.test_id,
532
+ {
533
+ status: row.status,
534
+ comment: row.comment,
535
+ ticket: row.ticket,
536
+ updatedAt: row.updated_at,
537
+ actor: row.actor,
538
+ revision: Number(row.revision || 1),
539
+ },
540
+ ]));
541
+ }
542
+
543
+ function persistAnnotation(database, annotation) {
544
+ if (!VALID_ANNOTATION_STATUSES.has(annotation.status)) {
545
+ throw new Error(`Estado de anotación inválido: ${annotation.status}`);
546
+ }
547
+
548
+ const updatedAt = annotation.updatedAt || new Date().toISOString();
549
+ const [current] = queryRows(
550
+ database,
551
+ `SELECT status, revision FROM test_annotations
552
+ WHERE run_id = ? AND test_id = ?`,
553
+ [annotation.runId, annotation.testId],
554
+ );
555
+ const [test] = queryRows(
556
+ database,
557
+ "SELECT status FROM tests WHERE run_id = ? AND test_id = ?",
558
+ [annotation.runId, annotation.testId],
559
+ );
560
+ const previousStatus = current?.status || test?.status || "unknown";
561
+ const actor = normalizeActor(annotation.actor);
562
+ const comment = sanitizeText(annotation.comment || "");
563
+ const ticket = sanitizeText(annotation.ticket || "", 2_000);
564
+ const revision = Number(current?.revision || 0) + 1;
565
+ database.run(
566
+ `INSERT OR REPLACE INTO test_annotations (
567
+ run_id, test_id, status, comment, ticket, updated_at, actor, revision
568
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
569
+ [
570
+ annotation.runId,
571
+ annotation.testId,
572
+ annotation.status,
573
+ comment,
574
+ ticket,
575
+ updatedAt,
576
+ actor,
577
+ revision,
578
+ ],
579
+ );
580
+ database.run(
581
+ `INSERT INTO annotation_events (
582
+ run_id, test_id, previous_status, status, comment, ticket,
583
+ actor, source, created_at
584
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
585
+ [
586
+ annotation.runId,
587
+ annotation.testId,
588
+ previousStatus,
589
+ annotation.status,
590
+ comment,
591
+ ticket,
592
+ actor,
593
+ sanitizeText(annotation.source || "manual", 80),
594
+ updatedAt,
595
+ ],
596
+ );
597
+ return updatedAt;
598
+ }
599
+
600
+ function loadAnnotationAudit(database, runId, testId) {
601
+ return queryRows(
602
+ database,
603
+ `SELECT previous_status, status, comment, ticket, actor, source, created_at
604
+ FROM annotation_events
605
+ WHERE run_id = ? AND test_id = ?
606
+ ORDER BY id DESC`,
607
+ [runId, testId],
608
+ );
609
+ }
610
+
611
+ function loadRunResults(database, runId) {
612
+ const [run] = queryRows(
613
+ database,
614
+ `SELECT id, environment, tag_expression, status, started_at, ended_at,
615
+ duration_ms, total, passed, failed, skipped, pending, flaky,
616
+ metadata_json
617
+ FROM runs
618
+ WHERE id = ?`,
619
+ [runId],
620
+ );
621
+ if (!run) return null;
622
+
623
+ const tests = queryRows(
624
+ database,
625
+ `SELECT t.test_id AS id, t.spec, t.title,
626
+ t.status AS original_status,
627
+ COALESCE(a.status, t.status) AS status,
628
+ t.duration_ms, t.retries, t.flaky, t.jira_id, t.tags_json,
629
+ t.http_json,
630
+ COALESCE(a.comment, '') AS comment,
631
+ COALESCE(a.ticket, '') AS ticket,
632
+ a.updated_at
633
+ FROM tests t
634
+ LEFT JOIN test_annotations a
635
+ ON a.run_id = t.run_id AND a.test_id = t.test_id
636
+ WHERE t.run_id = ?
637
+ ORDER BY t.spec, t.title`,
638
+ [runId],
639
+ );
640
+
641
+ for (const test of tests) {
642
+ try {
643
+ test.http = JSON.parse(test.http_json || "[]");
644
+ } catch {
645
+ test.http = [];
646
+ }
647
+ delete test.http_json;
648
+ }
649
+
650
+ let metadata = {};
651
+ try {
652
+ metadata = JSON.parse(run.metadata_json || "{}");
653
+ } catch {
654
+ metadata = {};
655
+ }
656
+ delete run.metadata_json;
657
+
658
+ return {
659
+ ...run,
660
+ browser: metadata.browser || {},
661
+ system: metadata.system || {},
662
+ source: metadata.source || {},
663
+ execution: metadata.execution || null,
664
+ specs: [...new Set(tests.map((test) => test.spec).filter(Boolean))],
665
+ tests,
666
+ };
667
+ }
668
+
669
+ function loadTestHistory(database, jiraId) {
670
+ const normalizedJiraId = String(jiraId || "").replace(/^@/, "").toUpperCase();
671
+ if (!/^[A-Z][A-Z0-9]+-\d+$/.test(normalizedJiraId)) return [];
672
+
673
+ return queryRows(
674
+ database,
675
+ `SELECT r.id AS run_id, r.environment, r.started_at,
676
+ t.test_id, t.title, t.spec, t.jira_id,
677
+ t.status AS original_status,
678
+ COALESCE(a.status, t.status) AS status,
679
+ t.duration_ms, t.retries, t.flaky,
680
+ COALESCE(a.comment, '') AS comment,
681
+ COALESCE(a.ticket, '') AS ticket,
682
+ a.updated_at
683
+ FROM tests t
684
+ JOIN runs r ON r.id = t.run_id
685
+ LEFT JOIN test_annotations a
686
+ ON a.run_id = t.run_id AND a.test_id = t.test_id
687
+ WHERE UPPER(t.jira_id) = ?
688
+ ORDER BY r.started_at DESC, t.title`,
689
+ [normalizedJiraId],
690
+ );
691
+ }
692
+
693
+ function buildQualityAnalytics(database, environment) {
694
+ const rows = queryRows(
695
+ database,
696
+ `SELECT t.logical_id, t.case_id, t.test_id, t.jira_id,
697
+ t.title, t.spec, t.status, t.flaky, t.duration_ms,
698
+ r.started_at
699
+ FROM tests t
700
+ JOIN runs r ON r.id = t.run_id
701
+ WHERE LOWER(r.environment) = LOWER(?)
702
+ ORDER BY r.started_at DESC, t.title`,
703
+ [environment],
704
+ );
705
+
706
+ const groups = new Map();
707
+ for (const row of rows) {
708
+ const key = canonicalTestIdentity(row);
709
+ if (!groups.has(key)) {
710
+ groups.set(key, {
711
+ logical_id: key,
712
+ jira_id: row.jira_id || "",
713
+ title: row.title,
714
+ spec: row.spec,
715
+ executions: 0,
716
+ failures: 0,
717
+ flaky_runs: 0,
718
+ duration_total: 0,
719
+ min_duration: Number.POSITIVE_INFINITY,
720
+ max_duration: 0,
721
+ last_seen: row.started_at,
722
+ history: [],
723
+ });
724
+ }
725
+
726
+ const group = groups.get(key);
727
+ const duration = Number(row.duration_ms || 0);
728
+ group.executions += 1;
729
+ group.failures += row.status === "failed" ? 1 : 0;
730
+ group.flaky_runs += Number(row.flaky || 0) ? 1 : 0;
731
+ group.duration_total += duration;
732
+ group.min_duration = Math.min(group.min_duration, duration);
733
+ group.max_duration = Math.max(group.max_duration, duration);
734
+ if (group.history.length < 5) {
735
+ group.history.push({
736
+ status: row.status,
737
+ flaky: Boolean(row.flaky),
738
+ startedAt: row.started_at,
739
+ });
740
+ }
741
+ }
742
+
743
+ const withHistory = [...groups.values()].map((item) => ({
744
+ ...item,
745
+ average_duration: item.executions
746
+ ? item.duration_total / item.executions
747
+ : 0,
748
+ min_duration: Number.isFinite(item.min_duration) ? item.min_duration : 0,
749
+ failure_rate: item.executions
750
+ ? Math.round((item.failures / item.executions) * 1000) / 10
751
+ : 0,
752
+ sample_sufficient: item.executions >= 3,
753
+ }));
754
+
755
+ return {
756
+ unstable: withHistory
757
+ .filter((item) => item.flaky_runs > 0)
758
+ .slice(0, 25),
759
+ recurrentFailures: withHistory
760
+ .filter((item) => item.failures > 0)
761
+ .sort((left, right) =>
762
+ right.failures - left.failures || right.failure_rate - left.failure_rate)
763
+ .slice(0, 25),
764
+ slowest: [...withHistory]
765
+ .sort((left, right) => Number(right.average_duration) - Number(left.average_duration))
766
+ .slice(0, 10),
767
+ };
768
+ }
769
+
770
+ function compareRuns(database, baseRunId, targetRunId) {
771
+ const load = (runId) => queryRows(
772
+ database,
773
+ `SELECT logical_id, test_id, title, spec, status, duration_ms
774
+ FROM tests WHERE run_id = ?`,
775
+ [runId],
776
+ );
777
+ const base = load(baseRunId);
778
+ const target = load(targetRunId);
779
+ const baseById = new Map(base.map((test) => [test.logical_id || test.test_id, test]));
780
+ const targetById = new Map(target.map((test) => [test.logical_id || test.test_id, test]));
781
+ const keys = new Set([...baseById.keys(), ...targetById.keys()]);
782
+ const changes = [];
783
+
784
+ for (const key of keys) {
785
+ const before = baseById.get(key);
786
+ const after = targetById.get(key);
787
+ let type = "unchanged";
788
+ if (!before) type = "added";
789
+ else if (!after) type = "removed";
790
+ else if (before.status !== "failed" && after.status === "failed") type = "new_failure";
791
+ else if (before.status === "failed" && after.status !== "failed") type = "resolved";
792
+ else if (before.status !== after.status) type = "status_changed";
793
+ else if (Number(after.duration_ms) > Number(before.duration_ms) * 1.5) type = "slower";
794
+ if (type !== "unchanged") changes.push({ key, type, before, after });
795
+ }
796
+ return { baseRunId, targetRunId, changes };
797
+ }
798
+
799
+ function buildTrends(database, currentRun, requestedLimit = 20) {
800
+ const limit = Math.max(10, Math.min(50, Number(requestedLimit || 20)));
801
+ const [runCount] = queryRows(
802
+ database,
803
+ `SELECT COUNT(*) AS total_runs
804
+ FROM runs
805
+ WHERE LOWER(environment) = LOWER(?)`,
806
+ [currentRun.environment],
807
+ );
808
+ const runs = queryRows(
809
+ database,
810
+ `SELECT r.id, r.environment, r.status, r.started_at, r.duration_ms,
811
+ COUNT(t.test_id) AS total,
812
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'passed' THEN 1 ELSE 0 END) AS passed,
813
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'failed' THEN 1 ELSE 0 END) AS failed,
814
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'skipped' THEN 1 ELSE 0 END) AS skipped,
815
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'pending' THEN 1 ELSE 0 END) AS pending,
816
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'environment_error' THEN 1 ELSE 0 END) AS environment_error,
817
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'precondition_error' THEN 1 ELSE 0 END) AS precondition_error,
818
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'outdated_test' THEN 1 ELSE 0 END) AS outdated_test,
819
+ SUM(CASE WHEN COALESCE(a.status, t.status) = 'reported' THEN 1 ELSE 0 END) AS reported,
820
+ r.flaky
821
+ FROM runs r
822
+ LEFT JOIN tests t ON t.run_id = r.id
823
+ LEFT JOIN test_annotations a
824
+ ON a.run_id = t.run_id AND a.test_id = t.test_id
825
+ WHERE LOWER(r.environment) = LOWER(?)
826
+ GROUP BY r.id, r.environment, r.status, r.started_at, r.duration_ms, r.flaky
827
+ ORDER BY r.started_at DESC
828
+ LIMIT ?`,
829
+ [currentRun.environment, limit],
830
+ ).reverse();
831
+
832
+ const flakyTests = queryRows(
833
+ database,
834
+ `SELECT t.test_id, t.title, t.spec,
835
+ SUM(CASE WHEN t.flaky = 1 THEN 1 ELSE 0 END) AS flaky_runs,
836
+ SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) AS failed_runs,
837
+ COUNT(*) AS executions
838
+ FROM tests t
839
+ JOIN runs r ON r.id = t.run_id
840
+ WHERE LOWER(r.environment) = LOWER(?)
841
+ GROUP BY t.test_id, t.title, t.spec
842
+ HAVING flaky_runs > 0 OR failed_runs > 0
843
+ ORDER BY flaky_runs DESC, failed_runs DESC
844
+ LIMIT 20`,
845
+ [currentRun.environment],
846
+ );
847
+
848
+ return {
849
+ runs,
850
+ flakyTests,
851
+ totalRuns: Number(runCount?.total_runs || 0),
852
+ };
853
+ }
854
+
855
+ function buildHtml(run) {
856
+ const serialized = JSON.stringify(run).replaceAll("<", "\\u003c");
857
+ return `<!doctype html>
858
+ <html lang="es">
859
+ <head>
860
+ <meta charset="UTF-8" />
861
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
862
+ <meta name="color-scheme" content="light" />
863
+ <title>Elmulo Reporter V2 · ${run.environment}</title>
864
+ <link rel="icon" href="assets/donkey-favicon.png" />
865
+ <link rel="stylesheet" href="assets/app.css" />
866
+ </head>
867
+ <body>
868
+ <div id="app"></div>
869
+ <script id="elmulo-data" type="application/json">${serialized}</script>
870
+ <script src="assets/app.js"></script>
871
+ </body>
872
+ </html>
873
+ `;
874
+ }
875
+
876
+ function copyStaticAssets(reportDir) {
877
+ // Assets belong to the reporter package, not to the consuming Cypress project.
878
+ const sourceDir = path.join(__dirname, "assets");
879
+ const destinationDir = path.join(reportDir, "assets");
880
+ ensureDir(destinationDir);
881
+ for (const filename of fs.readdirSync(sourceDir)) {
882
+ fs.copyFileSync(
883
+ path.join(sourceDir, filename),
884
+ path.join(destinationDir, filename),
885
+ );
886
+ }
887
+ }
888
+
889
+ function mergeHttpManifest(run, runDir) {
890
+ const manifest = readJson(path.join(runDir, "http", "manifest.json"), []);
891
+ if (!Array.isArray(manifest) || !manifest.length) return;
892
+
893
+ for (const test of run.tests || []) {
894
+ const exactKey = (test.titlePath || []).join(" › ");
895
+ const entry = manifest.find((candidate) => {
896
+ const candidateKey = String(candidate?.testKey || "");
897
+ if (candidateKey === exactKey || candidateKey === test.title) return true;
898
+ return Boolean(test.title) &&
899
+ candidateKey.endsWith(test.title) &&
900
+ (test.titlePath || []).every((part) => candidateKey.includes(part));
901
+ });
902
+ test.http = Array.isArray(entry?.exchanges) ? entry.exchanges : test.http || [];
903
+ }
904
+ }
905
+
906
+ async function finalizeRun(options = {}) {
907
+ const projectRoot = path.resolve(options.projectRoot || process.cwd());
908
+ const outputDir = path.resolve(
909
+ projectRoot,
910
+ options.outputDir || process.env.ELMULO_OUTPUT_DIR || "elmulo-results",
911
+ );
912
+ const latestRunPath = path.join(outputDir, "latest-run.txt");
913
+
914
+ if (!fs.existsSync(latestRunPath)) {
915
+ throw new Error(
916
+ "No hay una ejecución capturada. Ejecuta Cypress con Elmulo habilitado.",
917
+ );
918
+ }
919
+
920
+ const runId = fs.readFileSync(latestRunPath, "utf8").trim();
921
+ const runDir = path.join(outputDir, "runs", runId);
922
+ const rawPath = path.join(runDir, "run.raw.json");
923
+ const run = readJson(rawPath);
924
+ if (!run) throw new Error(`No se encontró ${rawPath}`);
925
+
926
+ run.schemaVersion = DATABASE_SCHEMA_VERSION;
927
+ run.reporterVersion = "2.0.0-beta.5";
928
+ run.lifecycle = run.lifecycle || "completed";
929
+ run.source = run.source || {
930
+ branch: process.env.CI_COMMIT_REF_NAME || "",
931
+ commit: process.env.CI_COMMIT_SHA || "",
932
+ pipelineId: process.env.CI_PIPELINE_ID || "",
933
+ jobId: process.env.CI_JOB_ID || "",
934
+ jobUrl: process.env.CI_JOB_URL || "",
935
+ };
936
+ mergeHttpManifest(run, runDir);
937
+ mergeCucumber(run, readCucumberScenarios(projectRoot));
938
+ enrichRunTests(run);
939
+ copyEvidence(projectRoot, runDir, run);
940
+
941
+ const databasePath = path.join(outputDir, "elmulo.sqlite");
942
+ const database = await openDatabase(databasePath);
943
+ persistRun(database, run);
944
+ backfillTestMetadata(database, outputDir);
945
+ run.annotations = loadAnnotations(database, run.id);
946
+ run.trends = buildTrends(database, run);
947
+ atomicWriteFile(databasePath, Buffer.from(database.export()));
948
+ database.close();
949
+
950
+ run.generatedAt = new Date().toISOString();
951
+ writeJson(path.join(runDir, "run.json"), run);
952
+
953
+ const reportDir = path.join(outputDir, "report");
954
+ ensureDir(reportDir);
955
+ copyStaticAssets(reportDir);
956
+ fs.writeFileSync(path.join(reportDir, "index.html"), buildHtml(run), "utf8");
957
+ writeJson(path.join(reportDir, "data.json"), run);
958
+
959
+ return { outputDir, reportDir, runDir, run };
960
+ }
961
+
962
+ module.exports = {
963
+ VALID_ANNOTATION_STATUSES,
964
+ backfillTestMetadata,
965
+ buildHtml,
966
+ buildTrends,
967
+ buildQualityAnalytics,
968
+ canonicalTestIdentity,
969
+ compareRuns,
970
+ enrichRunTests,
971
+ finalizeRun,
972
+ loadAnnotations,
973
+ loadAnnotationAudit,
974
+ loadRunResults,
975
+ loadTestHistory,
976
+ extractJiraId,
977
+ mergeCucumber,
978
+ normalizeTitle,
979
+ openDatabase,
980
+ persistAnnotation,
981
+ persistRun,
982
+ };