@alejandrojca/elmulo-reporter 2.0.0-beta.5 → 2.0.0-beta.6

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/README.md CHANGED
@@ -286,7 +286,49 @@ ELMULO_CAPTURE_HTTP=false npm run report-sandbox-ecommerce -- "@mtt"
286
286
  También puede configurarse `captureHttp: false` al llamar a
287
287
  `registerElmuloReporter`.
288
288
 
289
- ## Comandos
289
+ ### Reportar una falla en Jira
290
+
291
+ La pestaña **Error** permite crear un defecto en Jira cuando el reporte se abre
292
+ con `elmulo serve`. El comentario de la falla es obligatorio. Antes de enviar,
293
+ Elmulo muestra una confirmación y arma la descripción con:
294
+
295
+ - el escenario Gherkin ejecutado;
296
+ - el error final y el paso fallido;
297
+ - el request/response temporalmente asociado al fallo;
298
+ - el comentario escrito por la persona que analiza la corrida.
299
+
300
+ La integración está configurada por defecto para `FONLP06`, work type `Error` y
301
+ `Tipo = Mantenimiento (IT4IT)`. Busca duplicados mediante una label estable. Si
302
+ encuentra un defecto activo agrega un comentario; si todos los coincidentes
303
+ están en una categoría Jira `done`, crea una recurrencia.
304
+
305
+ Las credenciales nunca llegan al navegador. El servidor acepta cualquiera de
306
+ estas configuraciones:
307
+
308
+ ```powershell
309
+ $env:ELMULO_JIRA_EMAIL = "usuario@dominio.com"
310
+ $env:ELMULO_JIRA_API_TOKEN = "<api-token>"
311
+ npm run elmulo:serve
312
+ ```
313
+
314
+ También puede leer el archivo local y gitignoreado
315
+ `cypress/config/jira-credentials.ts` del proyecto Cypress. Para indicar otra
316
+ ruta:
317
+
318
+ ```powershell
319
+ $env:ELMULO_JIRA_CREDENTIALS_FILE = "C:\ruta\jira-credentials.ts"
320
+ npm run elmulo:serve
321
+ ```
322
+
323
+ Al crear o reutilizar el defecto, Elmulo cambia la prueba a `Reportado`, guarda
324
+ la URL en **Ticket / bug report** y registra la operación en la auditoría
325
+ SQLite.
326
+
327
+ La evidencia HTTP se envía literalmente a Jira. No se ocultan credenciales,
328
+ tokens, cookies ni datos de los cuerpos; esta política debe revisarse antes de
329
+ usar la integración con información productiva.
330
+
331
+ ## Comandos
290
332
 
291
333
  Los comandos se ejecutan desde la raíz del proyecto Cypress consumidor:
292
334
 
package/VERSION.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Elmulo Reporter",
3
- "version": "2.0.0-beta.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "schemaVersion": 3,
5
5
  "source": "elmulo-reporter",
6
6
  "results": "elmulo-results",
package/assets/app.css CHANGED
@@ -1398,12 +1398,109 @@ html[data-theme="dark"] .exampleRow .testMeta {
1398
1398
  resize: vertical;
1399
1399
  }
1400
1400
 
1401
- .failureReviewActions {
1401
+ .failureReviewActions {
1402
1402
  align-items: center;
1403
1403
  display: flex;
1404
1404
  gap: 0.75rem;
1405
1405
  margin-top: 0.85rem;
1406
- }
1406
+ }
1407
+
1408
+ .jiraDefectButton,
1409
+ .jiraDefectLink {
1410
+ align-items: center;
1411
+ background: #0c66e4;
1412
+ border: 1px solid #0c66e4;
1413
+ border-radius: 12px;
1414
+ color: #fff;
1415
+ display: inline-flex;
1416
+ font-weight: 800;
1417
+ justify-content: center;
1418
+ padding: 0.68rem 0.9rem;
1419
+ text-decoration: none;
1420
+ }
1421
+
1422
+ .jiraDefectButton:disabled {
1423
+ cursor: not-allowed;
1424
+ opacity: 0.45;
1425
+ }
1426
+
1427
+ .jiraDefectHint {
1428
+ color: var(--pw-gray-700);
1429
+ font-size: 0.76rem;
1430
+ margin: 0.55rem 0 0;
1431
+ }
1432
+
1433
+ .jiraDefectConfirmationBody {
1434
+ display: grid;
1435
+ gap: 1rem;
1436
+ }
1437
+
1438
+ .jiraDefectConfirmationBody dl {
1439
+ background: var(--pw-blue-050);
1440
+ border-radius: 14px;
1441
+ display: grid;
1442
+ gap: 0.65rem;
1443
+ margin: 0;
1444
+ padding: 0.9rem;
1445
+ }
1446
+
1447
+ .jiraDefectConfirmationBody dl > div {
1448
+ display: grid;
1449
+ gap: 0.15rem;
1450
+ grid-template-columns: minmax(110px, 0.35fr) 1fr;
1451
+ }
1452
+
1453
+ .jiraDefectConfirmationBody dt {
1454
+ color: var(--pw-gray-700);
1455
+ font-size: 0.74rem;
1456
+ font-weight: 800;
1457
+ }
1458
+
1459
+ .jiraDefectConfirmationBody dd {
1460
+ color: var(--pw-blue-900);
1461
+ font-size: 0.82rem;
1462
+ font-weight: 700;
1463
+ margin: 0;
1464
+ overflow-wrap: anywhere;
1465
+ }
1466
+
1467
+ .jiraDefectConfirmationBody section {
1468
+ border: 1px solid var(--pw-gray-200);
1469
+ border-radius: 14px;
1470
+ padding: 0.9rem;
1471
+ }
1472
+
1473
+ .jiraDefectConfirmationBody section h3,
1474
+ .jiraDefectConfirmationBody section p,
1475
+ .jiraDefectWarning {
1476
+ margin: 0;
1477
+ }
1478
+
1479
+ .jiraDefectConfirmationBody section p,
1480
+ .jiraDefectWarning {
1481
+ color: var(--pw-gray-700);
1482
+ font-size: 0.8rem;
1483
+ margin-top: 0.4rem;
1484
+ white-space: pre-wrap;
1485
+ }
1486
+
1487
+ html[data-theme="dark"] .jiraDefectConfirmationBody dl,
1488
+ html[data-theme="dark"] .jiraDefectConfirmationBody section {
1489
+ background: #102638;
1490
+ border-color: #315069;
1491
+ }
1492
+
1493
+ html[data-theme="dark"] .jiraDefectConfirmationBody dd,
1494
+ html[data-theme="dark"] .jiraDefectConfirmationBody section h3 {
1495
+ color: #e8f2ff;
1496
+ }
1497
+
1498
+ html[data-theme="dark"] .jiraDefectConfirmationBody dt,
1499
+ html[data-theme="dark"] .jiraDefectConfirmationBody section p,
1500
+ html[data-theme="dark"] .jiraDefectWarning,
1501
+ html[data-theme="dark"] .jiraDefectHint {
1502
+ color: #b8c7d6;
1503
+ }
1407
1504
 
1408
1505
  .saveReviewButton {
1409
1506
  background: var(--pw-blue-700);
package/assets/app.js CHANGED
@@ -1682,11 +1682,24 @@
1682
1682
  placeholder="Ej.: BUG-1234 o URL del ticket"
1683
1683
  />
1684
1684
  </label>
1685
- </div>
1686
- <div class="failureReviewActions">
1687
- <button class="saveReviewButton" type="button" data-save-failure-review>Guardar seguimiento</button>
1688
- <span class="saveFeedback" data-save-feedback aria-live="polite"></span>
1689
- </div>
1685
+ </div>
1686
+ <div class="failureReviewActions">
1687
+ <button class="saveReviewButton" type="button" data-save-failure-review>Guardar seguimiento</button>
1688
+ ${annotation.ticket
1689
+ ? `<a class="jiraDefectLink" href="${escapeHtml(annotation.ticket)}" target="_blank" rel="noreferrer">Abrir bug en Jira</a>`
1690
+ : `<button
1691
+ class="jiraDefectButton"
1692
+ type="button"
1693
+ data-create-jira-defect
1694
+ ${annotation.comment?.trim() && durablePersistence ? "" : "disabled"}
1695
+ >Crear defecto en Jira</button>`}
1696
+ <span class="saveFeedback" data-save-feedback aria-live="polite"></span>
1697
+ </div>
1698
+ ${!durablePersistence
1699
+ ? '<p class="jiraDefectHint">Abrí el reporte con Elmulo Serve para crear defectos en Jira.</p>'
1700
+ : !annotation.ticket
1701
+ ? '<p class="jiraDefectHint">El comentario es obligatorio antes de crear el defecto.</p>'
1702
+ : ""}
1690
1703
  ${annotation.updatedAt
1691
1704
  ? `<p class="auditSummary">Último cambio: ${escapeHtml(formatDate(annotation.updatedAt))} · ${escapeHtml(annotation.actor || "Usuario local")} · revisión ${escapeHtml(annotation.revision || 1)}</p>`
1692
1705
  : ""}
@@ -1694,8 +1707,96 @@
1694
1707
  <summary data-load-audit="${escapeHtml(test.id)}">Ver auditoría de cambios</summary>
1695
1708
  <div data-audit-events>Desplegá para consultar el historial inmutable.</div>
1696
1709
  </details>
1697
- </section>`;
1698
- }
1710
+ </section>`;
1711
+ }
1712
+
1713
+ function closeJiraDefectConfirmation() {
1714
+ document.getElementById("jira-defect-confirmation-modal")?.remove();
1715
+ }
1716
+
1717
+ function openJiraDefectConfirmation(test, comment) {
1718
+ closeJiraDefectConfirmation();
1719
+ const dialog = document.createElement("dialog");
1720
+ dialog.id = "jira-defect-confirmation-modal";
1721
+ dialog.className = "reuseConfirmationModal jiraDefectModal";
1722
+ dialog.innerHTML = `
1723
+ <form method="dialog" class="confirmationShell">
1724
+ <header>
1725
+ <div>
1726
+ <p class="eyebrow">Creación de defecto</p>
1727
+ <h2>¿Confirmás el reporte en Jira?</h2>
1728
+ </div>
1729
+ <button type="button" class="modalCloseButton" data-close-jira-defect aria-label="Cerrar">×</button>
1730
+ </header>
1731
+ <div class="confirmationBody jiraDefectConfirmationBody">
1732
+ <dl>
1733
+ <div><dt>Proyecto</dt><dd>FONLP06</dd></div>
1734
+ <div><dt>Tipo</dt><dd>Error</dd></div>
1735
+ <div><dt>Clasificación</dt><dd>Mantenimiento (IT4IT)</dd></div>
1736
+ <div><dt>Escenario</dt><dd>${escapeHtml(test.title)}</dd></div>
1737
+ <div><dt>Ambiente</dt><dd>${escapeHtml(run.environment || "Sin especificar")}</dd></div>
1738
+ </dl>
1739
+ <section>
1740
+ <h3>Comentario de la falla</h3>
1741
+ <p>${escapeHtml(comment)}</p>
1742
+ </section>
1743
+ <p class="jiraDefectWarning">Se enviarán a Jira el Gherkin ejecutado, el error final y el request/response asociados a la falla.</p>
1744
+ <div class="confirmationFeedback" data-jira-defect-feedback aria-live="polite"></div>
1745
+ </div>
1746
+ <footer>
1747
+ <button type="button" class="secondaryButton" data-close-jira-defect>Cancelar</button>
1748
+ <button type="button" class="jiraDefectButton" data-confirm-jira-defect>Crear defecto</button>
1749
+ </footer>
1750
+ </form>`;
1751
+ document.body.appendChild(dialog);
1752
+ dialog.querySelectorAll("[data-close-jira-defect]").forEach((button) => {
1753
+ button.addEventListener("click", closeJiraDefectConfirmation);
1754
+ });
1755
+ dialog.querySelector("[data-confirm-jira-defect]")?.addEventListener("click", async () => {
1756
+ const confirmButton = dialog.querySelector("[data-confirm-jira-defect]");
1757
+ const feedback = dialog.querySelector("[data-jira-defect-feedback]");
1758
+ confirmButton.disabled = true;
1759
+ feedback.textContent = "Consultando Jira y procesando el defecto…";
1760
+ feedback.className = "confirmationFeedback";
1761
+ try {
1762
+ const result = await requestElmuloJson("/api/jira/defects", {
1763
+ method: "POST",
1764
+ headers: { "Content-Type": "application/json" },
1765
+ body: JSON.stringify({
1766
+ runId: run.id,
1767
+ testId: test.id,
1768
+ comment,
1769
+ actor: state.actor,
1770
+ }),
1771
+ });
1772
+ state.annotations = result.annotations || state.annotations;
1773
+ run.annotations = state.annotations;
1774
+ run.trends = result.trends || run.trends;
1775
+ persistAnnotationsLocally();
1776
+ closeJiraDefectConfirmation();
1777
+ renderList();
1778
+ const detailFeedback = document.querySelector("[data-save-feedback]");
1779
+ if (detailFeedback) {
1780
+ const actionLabel = {
1781
+ created: "Defecto creado",
1782
+ recreated: "Recurrencia creada",
1783
+ reused: "El defecto existente fue actualizado",
1784
+ }[result.action] || "Defecto reportado";
1785
+ detailFeedback.textContent = `${actionLabel}: ${result.issueKey}.`;
1786
+ detailFeedback.classList.remove("error");
1787
+ }
1788
+ } catch (error) {
1789
+ feedback.textContent = error.message;
1790
+ feedback.className = "confirmationFeedback error";
1791
+ confirmButton.disabled = false;
1792
+ }
1793
+ });
1794
+ dialog.addEventListener("cancel", (event) => {
1795
+ event.preventDefault();
1796
+ closeJiraDefectConfirmation();
1797
+ });
1798
+ dialog.showModal();
1799
+ }
1699
1800
 
1700
1801
  async function loadAuditTrail(test, container) {
1701
1802
  container.innerHTML = "Consultando auditoría…";
@@ -2184,8 +2285,26 @@
2184
2285
  });
2185
2286
  }
2186
2287
 
2187
- const saveReviewButton = detail.querySelector("[data-save-failure-review]");
2188
- if (saveReviewButton) {
2288
+ const saveReviewButton = detail.querySelector("[data-save-failure-review]");
2289
+ const failureComment = detail.querySelector("[data-failure-comment]");
2290
+ const createJiraDefectButton = detail.querySelector("[data-create-jira-defect]");
2291
+ if (failureComment && createJiraDefectButton) {
2292
+ const syncJiraButton = () => {
2293
+ createJiraDefectButton.disabled =
2294
+ !failureComment.value.trim() || location.protocol === "file:";
2295
+ };
2296
+ failureComment.addEventListener("input", syncJiraButton);
2297
+ createJiraDefectButton.addEventListener("click", () => {
2298
+ const comment = failureComment.value.trim();
2299
+ if (!comment) {
2300
+ syncJiraButton();
2301
+ return;
2302
+ }
2303
+ openJiraDefectConfirmation(test, comment);
2304
+ });
2305
+ syncJiraButton();
2306
+ }
2307
+ if (saveReviewButton) {
2189
2308
  saveReviewButton.addEventListener("click", async () => {
2190
2309
  const comment = detail.querySelector("[data-failure-comment]")?.value.trim() || "";
2191
2310
  const ticket = detail.querySelector("[data-failure-ticket]")?.value.trim() || "";
package/finalize.cjs CHANGED
@@ -435,7 +435,7 @@ function persistRun(database, run) {
435
435
  system: run.system || {},
436
436
  source: run.source || {},
437
437
  execution: run.execution || null,
438
- reporterVersion: "2.0.0-beta.5",
438
+ reporterVersion: "2.0.0-beta.6",
439
439
  }),
440
440
  ],
441
441
  );
@@ -924,7 +924,7 @@ async function finalizeRun(options = {}) {
924
924
  if (!run) throw new Error(`No se encontró ${rawPath}`);
925
925
 
926
926
  run.schemaVersion = DATABASE_SCHEMA_VERSION;
927
- run.reporterVersion = "2.0.0-beta.5";
927
+ run.reporterVersion = "2.0.0-beta.6";
928
928
  run.lifecycle = run.lifecycle || "completed";
929
929
  run.source = run.source || {
930
930
  branch: process.env.CI_COMMIT_REF_NAME || "",
package/jira.cjs ADDED
@@ -0,0 +1,391 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ const DEFAULTS = Object.freeze({
6
+ baseUrl: "https://prismamediosdepago.atlassian.net",
7
+ projectKey: "FONLP06",
8
+ projectId: "10133",
9
+ issueTypeName: "Error",
10
+ issueTypeId: "10046",
11
+ tipoFieldId: "customfield_10431",
12
+ tipoValueId: "23025",
13
+ tipoValue: "Mantenimiento (IT4IT)",
14
+ });
15
+
16
+ function extractCredential(source, name) {
17
+ return String(source || "").match(
18
+ new RegExp(`["']?${name}["']?\\s*:\\s*["']([^"']*)["']`),
19
+ )?.[1] || "";
20
+ }
21
+
22
+ function loadJiraConfig(projectRoot, env = process.env) {
23
+ const credentialsPath = path.resolve(
24
+ env.ELMULO_JIRA_CREDENTIALS_FILE ||
25
+ path.join(projectRoot, "cypress", "config", "jira-credentials.ts"),
26
+ );
27
+ let fileCredentials = {};
28
+ if (fs.existsSync(credentialsPath)) {
29
+ const source = fs.readFileSync(credentialsPath, "utf8");
30
+ fileCredentials = {
31
+ authorization: extractCredential(source, "authorization"),
32
+ cookie: extractCredential(source, "cookie"),
33
+ projectKey: extractCredential(source, "project"),
34
+ projectId: extractCredential(source, "projectId"),
35
+ };
36
+ }
37
+
38
+ const email = String(env.ELMULO_JIRA_EMAIL || "").trim();
39
+ const apiToken = String(env.ELMULO_JIRA_API_TOKEN || "").trim();
40
+ const authorization = String(
41
+ env.ELMULO_JIRA_AUTHORIZATION ||
42
+ (email && apiToken
43
+ ? `Basic ${Buffer.from(`${email}:${apiToken}`, "utf8").toString("base64")}`
44
+ : fileCredentials.authorization || ""),
45
+ ).trim();
46
+
47
+ return {
48
+ ...DEFAULTS,
49
+ baseUrl: String(env.ELMULO_JIRA_BASE_URL || DEFAULTS.baseUrl).replace(/\/+$/, ""),
50
+ projectKey: String(
51
+ env.ELMULO_JIRA_PROJECT || fileCredentials.projectKey || DEFAULTS.projectKey,
52
+ ).trim(),
53
+ projectId: String(
54
+ env.ELMULO_JIRA_PROJECT_ID || fileCredentials.projectId || DEFAULTS.projectId,
55
+ ).trim(),
56
+ issueTypeId: String(env.ELMULO_JIRA_ISSUE_TYPE_ID || DEFAULTS.issueTypeId).trim(),
57
+ issueTypeName: String(
58
+ env.ELMULO_JIRA_ISSUE_TYPE_NAME || DEFAULTS.issueTypeName,
59
+ ).trim(),
60
+ tipoFieldId: String(env.ELMULO_JIRA_TIPO_FIELD_ID || DEFAULTS.tipoFieldId).trim(),
61
+ tipoValueId: String(env.ELMULO_JIRA_TIPO_VALUE_ID || DEFAULTS.tipoValueId).trim(),
62
+ tipoValue: String(env.ELMULO_JIRA_TIPO_VALUE || DEFAULTS.tipoValue).trim(),
63
+ authorization,
64
+ cookie: String(env.ELMULO_JIRA_COOKIE || fileCredentials.cookie || "").trim(),
65
+ credentialsPath,
66
+ enabled: Boolean(authorization),
67
+ };
68
+ }
69
+
70
+ function firstErrorLine(error = {}) {
71
+ const value = String(error.message || error.stack || error || "").trim();
72
+ return value.split(/\r?\n/).find((line) => line.trim())?.trim() || "Error sin detalle";
73
+ }
74
+
75
+ function normalizeFingerprintPart(value) {
76
+ return String(value || "")
77
+ .normalize("NFKD")
78
+ .replace(/[\u0300-\u036f]/g, "")
79
+ .replace(/\s+/g, " ")
80
+ .trim()
81
+ .toLowerCase();
82
+ }
83
+
84
+ function defectFingerprint(config, test) {
85
+ const source = [
86
+ config.projectKey,
87
+ String(test.spec || "").replaceAll("\\", "/"),
88
+ test.originalTitle || test.title,
89
+ firstErrorLine(test.error),
90
+ ].map(normalizeFingerprintPart).join("|");
91
+ return `elmulo-${crypto.createHash("sha256").update(source).digest("hex").slice(0, 12)}`;
92
+ }
93
+
94
+ function normalizeScenarioTitle(value) {
95
+ return normalizeFingerprintPart(value).replace(/\s+\(example\s+#\d+\)$/, "");
96
+ }
97
+
98
+ function sourceKeywords(projectRoot, test) {
99
+ const specPath = path.resolve(projectRoot, String(test.spec || ""));
100
+ if (!fs.existsSync(specPath)) return [];
101
+ const lines = fs.readFileSync(specPath, "utf8").split(/\r?\n/);
102
+ const target = normalizeScenarioTitle(test.originalTitle || test.title);
103
+ const scenarioPattern = /^\s*Scenario(?: Outline)?:\s*(.+?)\s*$/i;
104
+ let start = -1;
105
+ for (let index = 0; index < lines.length; index += 1) {
106
+ const match = lines[index].match(scenarioPattern);
107
+ if (match && normalizeScenarioTitle(match[1]) === target) {
108
+ start = index + 1;
109
+ break;
110
+ }
111
+ }
112
+ if (start < 0) return [];
113
+ const keywords = [];
114
+ for (let index = start; index < lines.length; index += 1) {
115
+ if (/^\s*(?:Scenario(?: Outline)?|Rule|Feature):/i.test(lines[index])) break;
116
+ if (/^\s*Examples:/i.test(lines[index])) break;
117
+ const match = lines[index].match(/^\s*(Given|When|Then|And|But|\*)\s+/i);
118
+ if (match) keywords.push(match[1]);
119
+ }
120
+ return keywords;
121
+ }
122
+
123
+ function buildExecutedGherkin(projectRoot, test) {
124
+ const keywords = sourceKeywords(projectRoot, test);
125
+ const tags = Array.isArray(test.tags) ? test.tags.join(" ") : "";
126
+ const steps = (test.steps || []).filter((step) => step.status !== "skipped");
127
+ return [
128
+ tags,
129
+ `Scenario: ${test.title || test.originalTitle || "Caso sin nombre"}`,
130
+ ...steps.map((step, index) =>
131
+ ` ${keywords[index] || (index === 0 ? "Given" : "And")} ${step.name}`),
132
+ ].filter(Boolean).join("\n");
133
+ }
134
+
135
+ function selectFailureExchange(test) {
136
+ const exchanges = Array.isArray(test.http) ? test.http : [];
137
+ if (!exchanges.length) return null;
138
+ const failedStep = (test.steps || []).find((step) => step.status === "failed");
139
+ if (!failedStep) return exchanges.at(-1);
140
+ const failureTime = Date.parse(failedStep.finishedAt || failedStep.startedAt || "");
141
+ if (!Number.isFinite(failureTime)) return exchanges.at(-1);
142
+ const eligible = exchanges.filter((exchange) => {
143
+ const startedAt = Date.parse(exchange.startedAt || "");
144
+ return Number.isFinite(startedAt) && startedAt <= failureTime;
145
+ });
146
+ return eligible.at(-1) || exchanges.at(-1);
147
+ }
148
+
149
+ function jiraDocument(blocks) {
150
+ return { type: "doc", version: 1, content: blocks };
151
+ }
152
+
153
+ function heading(text, level = 2) {
154
+ return {
155
+ type: "heading",
156
+ attrs: { level },
157
+ content: [{ type: "text", text: String(text) }],
158
+ };
159
+ }
160
+
161
+ function paragraph(text) {
162
+ return {
163
+ type: "paragraph",
164
+ content: String(text || "").split("\n").flatMap((line, index) => [
165
+ ...(index ? [{ type: "hardBreak" }] : []),
166
+ { type: "text", text: line || " " },
167
+ ]),
168
+ };
169
+ }
170
+
171
+ function codeBlock(value, language = "text") {
172
+ return {
173
+ type: "codeBlock",
174
+ attrs: { language },
175
+ content: [{ type: "text", text: String(value || "") }],
176
+ };
177
+ }
178
+
179
+ function prettyJson(value) {
180
+ if (typeof value === "string") {
181
+ try {
182
+ return JSON.stringify(JSON.parse(value), null, 2);
183
+ } catch {
184
+ return value;
185
+ }
186
+ }
187
+ return JSON.stringify(value ?? null, null, 2);
188
+ }
189
+
190
+ function relatedJiraCase(test) {
191
+ return (test.tags || [])
192
+ .map((tag) => String(tag).replace(/^@/, "").toUpperCase())
193
+ .find((tag) => /^[A-Z][A-Z0-9]+-\d+$/.test(tag)) || "";
194
+ }
195
+
196
+ function buildDescription({ config, projectRoot, run, test, comment, previousIssue }) {
197
+ const exchange = selectFailureExchange(test);
198
+ const failedStep = (test.steps || []).find((step) => step.status === "failed");
199
+ const blocks = [
200
+ heading("Caso de prueba"),
201
+ paragraph([
202
+ `Proyecto: ${config.projectKey}`,
203
+ `Feature: ${test.feature || test.titlePath?.[0] || ""}`,
204
+ `Archivo: ${test.spec || ""}`,
205
+ `Caso Xray: ${relatedJiraCase(test) || "Sin vinculación"}`,
206
+ `Ambiente: ${run.environment || "Sin especificar"}`,
207
+ `Fecha de ejecución: ${run.endedAt || run.finishedAt || new Date().toISOString()}`,
208
+ ].join("\n")),
209
+ codeBlock(buildExecutedGherkin(projectRoot, test), "gherkin"),
210
+ heading("Error final"),
211
+ codeBlock(String(test.error?.stack || test.error?.message || "Error sin detalle")),
212
+ ];
213
+ if (failedStep) {
214
+ blocks.push(paragraph("Paso que falló:"), codeBlock(
215
+ `${sourceKeywords(projectRoot, test)[failedStep.index] || "Then"} ${failedStep.name}`,
216
+ "gherkin",
217
+ ));
218
+ }
219
+ if (exchange) {
220
+ blocks.push(
221
+ heading("Request asociado a la falla"),
222
+ codeBlock(prettyJson(exchange.request), "json"),
223
+ heading("Respuesta asociada a la falla"),
224
+ codeBlock(prettyJson(exchange.response), "json"),
225
+ );
226
+ } else {
227
+ blocks.push(heading("Request y respuesta"), paragraph(
228
+ "Elmulo no registró un intercambio HTTP asociado a esta falla.",
229
+ ));
230
+ }
231
+ blocks.push(heading("Comentario de la falla"), paragraph(comment));
232
+ if (previousIssue) {
233
+ blocks.push(
234
+ heading("Recurrencia"),
235
+ paragraph(
236
+ `Este defecto es una recurrencia de ${previousIssue.key}, actualmente finalizado: ` +
237
+ `${config.baseUrl}/browse/${previousIssue.key}`,
238
+ ),
239
+ );
240
+ }
241
+ return jiraDocument(blocks);
242
+ }
243
+
244
+ function defectSummary(test, recurrence = false) {
245
+ const failedStep = (test.steps || []).find((step) => step.status === "failed");
246
+ const prefix = recurrence ? "[RECURRENCIA]" : "";
247
+ const mtt = (test.tags || []).some((tag) => String(tag).toLowerCase() === "@mtt")
248
+ ? "[MTT]"
249
+ : "[Elmulo]";
250
+ const detail = failedStep
251
+ ? `${test.title}: ${firstErrorLine({ message: failedStep.error || firstErrorLine(test.error) })}`
252
+ : `${test.title}: ${firstErrorLine(test.error)}`;
253
+ return `${prefix}${mtt} ${detail}`.slice(0, 255);
254
+ }
255
+
256
+ function recurrenceComment({ run, test, comment }) {
257
+ const failedStep = (test.steps || []).find((step) => step.status === "failed");
258
+ return jiraDocument([
259
+ heading("Recurrencia detectada por Elmulo", 3),
260
+ paragraph("El error continúa sucediendo."),
261
+ paragraph([
262
+ `Fecha: ${run.endedAt || run.finishedAt || new Date().toISOString()}`,
263
+ `Ambiente: ${run.environment || "Sin especificar"}`,
264
+ `Caso: ${relatedJiraCase(test) || test.title}`,
265
+ `Error: ${failedStep?.error || firstErrorLine(test.error)}`,
266
+ `Comentario: ${comment}`,
267
+ ].join("\n")),
268
+ ]);
269
+ }
270
+
271
+ async function jiraRequest(config, pathname, options = {}) {
272
+ if (!config.enabled) {
273
+ throw new Error(
274
+ `Jira no está configurado. Definí ELMULO_JIRA_EMAIL/ELMULO_JIRA_API_TOKEN ` +
275
+ `o creá ${config.credentialsPath}.`,
276
+ );
277
+ }
278
+ const headers = {
279
+ Accept: "application/json",
280
+ Authorization: config.authorization,
281
+ "Content-Type": "application/json",
282
+ ...(config.cookie ? { Cookie: config.cookie } : {}),
283
+ ...(options.headers || {}),
284
+ };
285
+ const response = await fetch(`${config.baseUrl}${pathname}`, {
286
+ ...options,
287
+ headers,
288
+ });
289
+ const text = await response.text();
290
+ let data = {};
291
+ try {
292
+ data = text ? JSON.parse(text) : {};
293
+ } catch {
294
+ data = { message: text };
295
+ }
296
+ if (!response.ok) {
297
+ const detail = data.errorMessages?.join("; ") ||
298
+ Object.entries(data.errors || {}).map(([field, message]) => `${field}: ${message}`).join("; ") ||
299
+ data.message || `HTTP ${response.status}`;
300
+ throw new Error(`Jira rechazó la operación: ${detail}`);
301
+ }
302
+ return data;
303
+ }
304
+
305
+ async function findMatchingIssues(config, fingerprint) {
306
+ const jql = [
307
+ `project = "${config.projectKey}"`,
308
+ `issuetype = "${config.issueTypeName}"`,
309
+ `labels = "${fingerprint}"`,
310
+ "ORDER BY created DESC",
311
+ ].join(" AND ");
312
+ const query = new URLSearchParams({
313
+ jql,
314
+ fields: "summary,status,labels",
315
+ maxResults: "50",
316
+ });
317
+ const data = await jiraRequest(config, `/rest/api/3/search/jql?${query}`);
318
+ return data.issues || [];
319
+ }
320
+
321
+ async function addRecurrenceComment(config, issueKey, context) {
322
+ await jiraRequest(config, `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
323
+ method: "POST",
324
+ body: JSON.stringify({ body: recurrenceComment(context) }),
325
+ });
326
+ }
327
+
328
+ async function createIssue(config, context, fingerprint, previousIssue) {
329
+ const myself = await jiraRequest(config, "/rest/api/3/myself");
330
+ const data = await jiraRequest(config, "/rest/api/3/issue", {
331
+ method: "POST",
332
+ body: JSON.stringify({
333
+ fields: {
334
+ project: { id: config.projectId },
335
+ issuetype: { id: config.issueTypeId },
336
+ summary: defectSummary(context.test, Boolean(previousIssue)),
337
+ description: buildDescription({ config, ...context, previousIssue }),
338
+ reporter: { accountId: myself.accountId },
339
+ [config.tipoFieldId]: { id: config.tipoValueId },
340
+ labels: [fingerprint, "elmulo-reporter"],
341
+ },
342
+ }),
343
+ });
344
+ return { id: data.id, key: data.key };
345
+ }
346
+
347
+ async function reportDefect({ config, projectRoot, run, test, comment }) {
348
+ if (!String(comment || "").trim()) {
349
+ throw new Error("El comentario de la falla es obligatorio.");
350
+ }
351
+ if (test.status !== "failed") {
352
+ throw new Error("Solo se pueden reportar pruebas fallidas.");
353
+ }
354
+ const fingerprint = defectFingerprint(config, test);
355
+ const matches = await findMatchingIssues(config, fingerprint);
356
+ const active = matches.find(
357
+ (issue) => issue.fields?.status?.statusCategory?.key !== "done",
358
+ );
359
+ const context = { projectRoot, run, test, comment: String(comment).trim() };
360
+ if (active) {
361
+ await addRecurrenceComment(config, active.key, context);
362
+ return {
363
+ action: "reused",
364
+ fingerprint,
365
+ issueKey: active.key,
366
+ issueUrl: `${config.baseUrl}/browse/${active.key}`,
367
+ };
368
+ }
369
+ const previousIssue = matches[0] || null;
370
+ const created = await createIssue(config, context, fingerprint, previousIssue);
371
+ return {
372
+ action: previousIssue ? "recreated" : "created",
373
+ fingerprint,
374
+ issueKey: created.key,
375
+ issueUrl: `${config.baseUrl}/browse/${created.key}`,
376
+ previousIssueKey: previousIssue?.key || "",
377
+ };
378
+ }
379
+
380
+ module.exports = {
381
+ DEFAULTS,
382
+ buildDescription,
383
+ buildExecutedGherkin,
384
+ defectFingerprint,
385
+ defectSummary,
386
+ firstErrorLine,
387
+ loadJiraConfig,
388
+ reportDefect,
389
+ selectFailureExchange,
390
+ sourceKeywords,
391
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alejandrojca/elmulo-reporter",
3
- "version": "2.0.0-beta.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "Reporter independiente para ejecuciones de Cypress con dashboard, historial SQLite y evidencias.",
5
5
  "type": "commonjs",
6
6
  "main": "plugin.cjs",
@@ -20,6 +20,7 @@
20
20
  "core.cjs",
21
21
  "executive-pdf.cjs",
22
22
  "finalize.cjs",
23
+ "jira.cjs",
23
24
  "plugin.cjs",
24
25
  "rerun.cjs",
25
26
  "security.cjs",
package/plugin.cjs CHANGED
@@ -57,7 +57,7 @@ function registerElmuloReporter(on, config, options = {}) {
57
57
  "unknown",
58
58
  tagExpression: config.env?.TAGS || process.env.CYPRESS_TAGS || "",
59
59
  lifecycle: "running",
60
- reporterVersion: "2.0.0-beta.5",
60
+ reporterVersion: "2.0.0-beta.6",
61
61
  execution: executionScript
62
62
  ? { runner: "npm", script: executionScript, args: executionArgs }
63
63
  : null,
package/server.cjs CHANGED
@@ -19,8 +19,9 @@ const {
19
19
  PDF_SECTIONS,
20
20
  buildExecutivePdf,
21
21
  } = require("./executive-pdf.cjs");
22
- const { normalizeActor, sanitizeText } = require("./security.cjs");
23
- const { createRerunManager } = require("./rerun.cjs");
22
+ const { normalizeActor, sanitizeText } = require("./security.cjs");
23
+ const { createRerunManager } = require("./rerun.cjs");
24
+ const { loadJiraConfig, reportDefect } = require("./jira.cjs");
24
25
 
25
26
  const MIME_TYPES = {
26
27
  ".css": "text/css; charset=utf-8",
@@ -136,8 +137,9 @@ async function serveReport(options = {}) {
136
137
  }
137
138
 
138
139
  let writeQueue = Promise.resolve();
139
- const rerunManager = createRerunManager(projectRoot, options.rerun || {});
140
- let allowedMutationOrigins = new Set();
140
+ const rerunManager = createRerunManager(projectRoot, options.rerun || {});
141
+ const jiraConfig = loadJiraConfig(projectRoot, options.env || process.env);
142
+ let allowedMutationOrigins = new Set();
141
143
 
142
144
  const server = http.createServer(async (request, response) => {
143
145
  const url = new URL(request.url || "/", `http://${request.headers.host || host}`);
@@ -177,14 +179,23 @@ async function serveReport(options = {}) {
177
179
  return;
178
180
  }
179
181
 
180
- if (request.method === "GET" && url.pathname === "/api/health") {
182
+ if (request.method === "GET" && url.pathname === "/api/health") {
181
183
  sendJson(response, 200, {
182
184
  ok: true,
183
- version: "2.0.0-beta.5",
185
+ version: "2.0.0-beta.6",
184
186
  schemaVersion: 3,
185
187
  });
186
- return;
187
- }
188
+ return;
189
+ }
190
+
191
+ if (request.method === "GET" && url.pathname === "/api/jira/status") {
192
+ sendJson(response, 200, {
193
+ enabled: jiraConfig.enabled,
194
+ projectKey: jiraConfig.projectKey,
195
+ issueType: jiraConfig.issueTypeName,
196
+ });
197
+ return;
198
+ }
188
199
 
189
200
  if (request.method === "GET" && url.pathname === "/api/export/executive.pdf") {
190
201
  const requestedSections = url.searchParams.has("sections")
@@ -446,7 +457,7 @@ async function serveReport(options = {}) {
446
457
  return;
447
458
  }
448
459
 
449
- if (request.method === "POST" && url.pathname === "/api/annotations") {
460
+ if (request.method === "POST" && url.pathname === "/api/annotations") {
450
461
  try {
451
462
  const body = await readRequestJson(request);
452
463
  writeQueue = writeQueue.catch(() => {}).then(async () => {
@@ -488,8 +499,68 @@ async function serveReport(options = {}) {
488
499
  } catch (error) {
489
500
  sendJson(response, 400, { error: error.message });
490
501
  }
491
- return;
492
- }
502
+ return;
503
+ }
504
+
505
+ if (request.method === "POST" && url.pathname === "/api/jira/defects") {
506
+ if (!trustedMutationOrigin(request, allowedMutationOrigins)) {
507
+ sendJson(response, 403, { error: "Origen no autorizado para reportar defectos." }, {
508
+ allowAnyOrigin: false,
509
+ });
510
+ return;
511
+ }
512
+ try {
513
+ const body = await readRequestJson(request);
514
+ writeQueue = writeQueue.catch(() => {}).then(async () => {
515
+ const run = JSON.parse(fs.readFileSync(dataPath, "utf8"));
516
+ const test = run.tests.find((candidate) => candidate.id === body.testId);
517
+ if (body.runId !== run.id || !test) {
518
+ throw new Error("La prueba no pertenece al reporte activo.");
519
+ }
520
+ const comment = String(body.comment || "").trim();
521
+ const defect = await reportDefect({
522
+ config: jiraConfig,
523
+ projectRoot,
524
+ run,
525
+ test,
526
+ comment,
527
+ });
528
+
529
+ const database = await openDatabase(databasePath);
530
+ persistAnnotation(database, {
531
+ runId: run.id,
532
+ testId: test.id,
533
+ status: "reported",
534
+ comment,
535
+ ticket: defect.issueUrl,
536
+ actor: normalizeActor(body.actor),
537
+ source: `jira_${defect.action}`,
538
+ });
539
+ run.annotations = loadAnnotations(database, run.id);
540
+ run.trends = buildTrends(database, run);
541
+ persistDatabase(databasePath, database);
542
+ database.close();
543
+ writeJson(dataPath, run);
544
+ fs.writeFileSync(indexPath, buildHtml(run), "utf8");
545
+ return {
546
+ ...defect,
547
+ annotation: run.annotations[test.id],
548
+ annotations: run.annotations,
549
+ trends: run.trends,
550
+ };
551
+ });
552
+ sendJson(response, 200, await writeQueue, {
553
+ allowAnyOrigin: false,
554
+ allowOrigin: String(request.headers.origin || ""),
555
+ });
556
+ } catch (error) {
557
+ sendJson(response, 400, { error: error.message }, {
558
+ allowAnyOrigin: false,
559
+ allowOrigin: String(request.headers.origin || ""),
560
+ });
561
+ }
562
+ return;
563
+ }
493
564
 
494
565
  if (request.method === "POST" && url.pathname === "/api/bulk-annotations") {
495
566
  try {