@alejandrojca/elmulo-reporter 2.0.0-beta.5 → 2.0.0-beta.7
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 +43 -1
- package/VERSION.json +1 -1
- package/assets/app.css +99 -2
- package/assets/app.js +128 -9
- package/finalize.cjs +26 -16
- package/jira.cjs +391 -0
- package/package.json +2 -1
- package/plugin.cjs +1 -1
- package/server.cjs +82 -11
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
|
-
|
|
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
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
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
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.
|
|
438
|
+
reporterVersion: "2.0.0-beta.7",
|
|
439
439
|
}),
|
|
440
440
|
],
|
|
441
441
|
);
|
|
@@ -873,19 +873,27 @@ function buildHtml(run) {
|
|
|
873
873
|
`;
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
-
function
|
|
877
|
-
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
876
|
+
function copyDirectoryContents(sourceDir, destinationDir) {
|
|
877
|
+
ensureDir(destinationDir);
|
|
878
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
879
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
880
|
+
const destinationPath = path.join(destinationDir, entry.name);
|
|
881
|
+
if (entry.isDirectory()) {
|
|
882
|
+
copyDirectoryContents(sourcePath, destinationPath);
|
|
883
|
+
} else if (entry.isFile()) {
|
|
884
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function copyStaticAssets(reportDir) {
|
|
890
|
+
// Assets belong to the reporter package, not to the consuming Cypress project.
|
|
891
|
+
copyDirectoryContents(
|
|
892
|
+
path.join(__dirname, "assets"),
|
|
893
|
+
path.join(reportDir, "assets"),
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
889
897
|
function mergeHttpManifest(run, runDir) {
|
|
890
898
|
const manifest = readJson(path.join(runDir, "http", "manifest.json"), []);
|
|
891
899
|
if (!Array.isArray(manifest) || !manifest.length) return;
|
|
@@ -924,7 +932,7 @@ async function finalizeRun(options = {}) {
|
|
|
924
932
|
if (!run) throw new Error(`No se encontró ${rawPath}`);
|
|
925
933
|
|
|
926
934
|
run.schemaVersion = DATABASE_SCHEMA_VERSION;
|
|
927
|
-
run.reporterVersion = "2.0.0-beta.
|
|
935
|
+
run.reporterVersion = "2.0.0-beta.7";
|
|
928
936
|
run.lifecycle = run.lifecycle || "completed";
|
|
929
937
|
run.source = run.source || {
|
|
930
938
|
branch: process.env.CI_COMMIT_REF_NAME || "",
|
|
@@ -966,7 +974,9 @@ module.exports = {
|
|
|
966
974
|
buildTrends,
|
|
967
975
|
buildQualityAnalytics,
|
|
968
976
|
canonicalTestIdentity,
|
|
969
|
-
compareRuns,
|
|
977
|
+
compareRuns,
|
|
978
|
+
copyDirectoryContents,
|
|
979
|
+
copyStaticAssets,
|
|
970
980
|
enrichRunTests,
|
|
971
981
|
finalizeRun,
|
|
972
982
|
loadAnnotations,
|
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.
|
|
3
|
+
"version": "2.0.0-beta.7",
|
|
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.
|
|
60
|
+
reporterVersion: "2.0.0-beta.7",
|
|
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
|
-
|
|
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.
|
|
185
|
+
version: "2.0.0-beta.7",
|
|
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 {
|