@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/README.md +412 -0
- package/VERSION.json +9 -0
- package/assets/app.css +4457 -0
- package/assets/app.js +3014 -0
- package/assets/donkey-favicon.png +0 -0
- package/assets/donkey-silhouette-white.png +0 -0
- package/assets/donkey-silhouette.png +0 -0
- package/assets/fonts/NotoSans-Variable.ttf +0 -0
- package/assets/fonts/OFL.txt +93 -0
- package/cli.cjs +272 -0
- package/core.cjs +274 -0
- package/executive-pdf.cjs +713 -0
- package/finalize.cjs +982 -0
- package/package.json +65 -0
- package/plugin.cjs +249 -0
- package/rerun.cjs +298 -0
- package/security.cjs +56 -0
- package/server.cjs +569 -0
- package/support.ts +210 -0
package/assets/app.js
ADDED
|
@@ -0,0 +1,3014 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const dataElement = document.getElementById("elmulo-data");
|
|
3
|
+
const run = JSON.parse(dataElement.textContent);
|
|
4
|
+
const app = document.getElementById("app");
|
|
5
|
+
const annotationStorageKey = `elmulo-annotations:${run.id}`;
|
|
6
|
+
const actorStorageKey = "elmulo-v2-actor";
|
|
7
|
+
const preferenceStorageKey = "elmulo-v2-preferences";
|
|
8
|
+
const initialUrlState = new URLSearchParams(window.location.search);
|
|
9
|
+
const initialTrendEnvironment =
|
|
10
|
+
String(run.environment || "").toLowerCase() === "qa" ? "qa" : "sandbox";
|
|
11
|
+
const availableViews = new Set([
|
|
12
|
+
"overview",
|
|
13
|
+
"executions",
|
|
14
|
+
"quality",
|
|
15
|
+
"analysis",
|
|
16
|
+
"tests",
|
|
17
|
+
"preferences",
|
|
18
|
+
]);
|
|
19
|
+
const initialView = availableViews.has(initialUrlState.get("view"))
|
|
20
|
+
? initialUrlState.get("view")
|
|
21
|
+
: "overview";
|
|
22
|
+
|
|
23
|
+
const customErrorStatuses = {
|
|
24
|
+
environment_error: "Error de ambiente",
|
|
25
|
+
precondition_error: "Error de precondición",
|
|
26
|
+
outdated_test: "Prueba desactualizada",
|
|
27
|
+
reported: "Reportado",
|
|
28
|
+
};
|
|
29
|
+
const editableFailureStatuses = new Set([
|
|
30
|
+
"failed",
|
|
31
|
+
...Object.keys(customErrorStatuses),
|
|
32
|
+
]);
|
|
33
|
+
const persistedAnnotationStatuses = new Set([
|
|
34
|
+
"passed",
|
|
35
|
+
"skipped",
|
|
36
|
+
"pending",
|
|
37
|
+
...editableFailureStatuses,
|
|
38
|
+
]);
|
|
39
|
+
const otherErrorStatuses = new Set([
|
|
40
|
+
...Object.keys(customErrorStatuses),
|
|
41
|
+
]);
|
|
42
|
+
const pdfSections = [
|
|
43
|
+
["summary", "Resumen ejecutivo", "Indicadores principales y evaluación general.", true],
|
|
44
|
+
["statusDistribution", "Distribución por estado", "Resultados automáticos y clasificaciones manuales.", true],
|
|
45
|
+
["runContext", "Contexto de la corrida", "Ambiente, fecha, navegador, tags, rama y pipeline.", true],
|
|
46
|
+
["features", "Resultados por Feature", "Totales y estados agrupados por Feature.", true],
|
|
47
|
+
["issues", "Problemas que requieren seguimiento", "Fallos, Jira, comentarios y tickets asociados.", true],
|
|
48
|
+
["previousComparison", "Comparación con la corrida anterior", "Variaciones de resultados y duración.", true],
|
|
49
|
+
["recentHistory", "Historial reciente", "Últimas ejecuciones del mismo ambiente.", true],
|
|
50
|
+
["flaky", "Pruebas inestables", "Casos que pasaron después de uno o más reintentos.", true],
|
|
51
|
+
["recurrentFailures", "Fallos recurrentes", "Pruebas con fallos repetidos en el historial.", true],
|
|
52
|
+
["slowTests", "Pruebas más lentas", "Ranking histórico de pruebas por duración.", false],
|
|
53
|
+
["recommendation", "Recomendación y pendientes", "Evaluación final, fallos sin comentario y sin ticket.", true],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function readLocalJson(key, fallback) {
|
|
57
|
+
try {
|
|
58
|
+
const value = JSON.parse(localStorage.getItem(key) || "null");
|
|
59
|
+
return value && typeof value === "object" ? value : fallback;
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const preferences = readLocalJson(preferenceStorageKey, {
|
|
66
|
+
density: "comfortable",
|
|
67
|
+
theme: "light",
|
|
68
|
+
sidebarCollapsed: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function loadAnnotations() {
|
|
72
|
+
let localAnnotations = {};
|
|
73
|
+
try {
|
|
74
|
+
const stored = localStorage.getItem(annotationStorageKey);
|
|
75
|
+
const parsed = stored ? JSON.parse(stored) : {};
|
|
76
|
+
localAnnotations = parsed && typeof parsed === "object" ? parsed : {};
|
|
77
|
+
} catch {
|
|
78
|
+
localAnnotations = {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const annotations = {
|
|
82
|
+
...localAnnotations,
|
|
83
|
+
...(run.annotations || {}),
|
|
84
|
+
};
|
|
85
|
+
for (const annotation of Object.values(annotations)) {
|
|
86
|
+
if (annotation?.status === "fix_in_progress") {
|
|
87
|
+
annotation.status = "reported";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return annotations;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function persistAnnotationsLocally() {
|
|
94
|
+
try {
|
|
95
|
+
localStorage.setItem(annotationStorageKey, JSON.stringify(state.annotations));
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function requestElmuloJson(apiPath, options = {}) {
|
|
103
|
+
const localServerUrl = new URL(apiPath, "http://127.0.0.1:4178").href;
|
|
104
|
+
const currentOriginUrl = new URL(apiPath, window.location.href).href;
|
|
105
|
+
const candidates = [...new Set([currentOriginUrl, localServerUrl])];
|
|
106
|
+
let lastError = null;
|
|
107
|
+
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(candidate, options);
|
|
111
|
+
const bodyText = await response.text();
|
|
112
|
+
let result = null;
|
|
113
|
+
try {
|
|
114
|
+
result = bodyText ? JSON.parse(bodyText) : {};
|
|
115
|
+
} catch {
|
|
116
|
+
lastError = new Error(
|
|
117
|
+
`El servidor respondió ${response.status} sin datos válidos de Elmulo.`,
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw new Error(result.error || `La consulta respondió ${response.status}.`);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
lastError = error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new Error(
|
|
132
|
+
lastError?.message ||
|
|
133
|
+
"No se pudo conectar con Elmulo V2 en http://127.0.0.1:4178.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function persistAnnotation(testId) {
|
|
138
|
+
persistAnnotationsLocally();
|
|
139
|
+
const annotation = state.annotations[testId];
|
|
140
|
+
if (!annotation || location.protocol === "file:") {
|
|
141
|
+
state.persistenceStatus = "local";
|
|
142
|
+
renderSaveStatus();
|
|
143
|
+
return { durable: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
state.persistenceStatus = "saving";
|
|
148
|
+
renderSaveStatus();
|
|
149
|
+
const result = await requestElmuloJson("/api/annotations", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
runId: run.id,
|
|
154
|
+
testId,
|
|
155
|
+
status: annotation.status,
|
|
156
|
+
comment: annotation.comment || "",
|
|
157
|
+
ticket: annotation.ticket || "",
|
|
158
|
+
actor: state.actor,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
state.annotations = result.annotations || state.annotations;
|
|
163
|
+
run.annotations = state.annotations;
|
|
164
|
+
if (state.trendEnvironment === initialTrendEnvironment) {
|
|
165
|
+
run.trends = result.trends || run.trends;
|
|
166
|
+
}
|
|
167
|
+
persistAnnotationsLocally();
|
|
168
|
+
renderSummary();
|
|
169
|
+
renderTrendArea();
|
|
170
|
+
state.persistenceStatus = "saved";
|
|
171
|
+
renderSaveStatus();
|
|
172
|
+
return { durable: true };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
state.persistenceStatus = "error";
|
|
175
|
+
renderSaveStatus(error.message);
|
|
176
|
+
return { durable: false, error: error.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderSaveStatus(errorMessage = "") {
|
|
181
|
+
const status = document.getElementById("save-status");
|
|
182
|
+
if (!status) return;
|
|
183
|
+
const content = {
|
|
184
|
+
saved: ["saved", "Guardado en SQLite"],
|
|
185
|
+
saving: ["saving", "Guardando…"],
|
|
186
|
+
local: ["local", "Sólo navegador"],
|
|
187
|
+
error: ["error", "Error al guardar"],
|
|
188
|
+
}[state.persistenceStatus] || ["saved", "Guardado en SQLite"];
|
|
189
|
+
status.className = `saveStatus ${content[0]}`;
|
|
190
|
+
status.textContent = content[1];
|
|
191
|
+
status.title = errorMessage ||
|
|
192
|
+
(state.persistenceStatus === "local"
|
|
193
|
+
? "Abrí el reporte con Elmulo Serve para guardar en SQLite."
|
|
194
|
+
: "");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const state = {
|
|
198
|
+
search: initialUrlState.get("q") || "",
|
|
199
|
+
status: initialUrlState.get("status") || "all",
|
|
200
|
+
spec: initialUrlState.get("spec") || "all",
|
|
201
|
+
tag: initialUrlState.get("tag") || "all",
|
|
202
|
+
trendStatus: "total",
|
|
203
|
+
trendEnvironment: initialTrendEnvironment,
|
|
204
|
+
selectedTrendRunId: null,
|
|
205
|
+
historicalRun: null,
|
|
206
|
+
historicalRerun: null,
|
|
207
|
+
historyJiraId: null,
|
|
208
|
+
historyTestTitle: "",
|
|
209
|
+
historyCurrentTestId: null,
|
|
210
|
+
historyEnvironment: null,
|
|
211
|
+
selectedHistoryKey: null,
|
|
212
|
+
testHistoryEntries: null,
|
|
213
|
+
testHistoryError: "",
|
|
214
|
+
historyReuseMessage: "",
|
|
215
|
+
debugTestIds: new Set(),
|
|
216
|
+
expandedExampleGroups: new Set(),
|
|
217
|
+
annotations: loadAnnotations(),
|
|
218
|
+
selectedId: initialUrlState.get("test"),
|
|
219
|
+
flaky: initialUrlState.get("flaky") || "all",
|
|
220
|
+
sort: initialUrlState.get("sort") || "severity",
|
|
221
|
+
actor: (() => {
|
|
222
|
+
try {
|
|
223
|
+
return localStorage.getItem(actorStorageKey) || "Usuario local";
|
|
224
|
+
} catch {
|
|
225
|
+
return "Usuario local";
|
|
226
|
+
}
|
|
227
|
+
})(),
|
|
228
|
+
bulkSelected: new Set(),
|
|
229
|
+
analytics: null,
|
|
230
|
+
comparison: null,
|
|
231
|
+
attentionFilter: "",
|
|
232
|
+
qualityTab: "flaky",
|
|
233
|
+
detailTab: "steps",
|
|
234
|
+
trendLimit: Number(initialUrlState.get("runs") || 20),
|
|
235
|
+
persistenceStatus: location.protocol === "file:" ? "local" : "saved",
|
|
236
|
+
density: preferences.density || "comfortable",
|
|
237
|
+
theme: preferences.theme || "light",
|
|
238
|
+
view: initialView,
|
|
239
|
+
sidebarCollapsed: Boolean(preferences.sidebarCollapsed),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function syncUrlState(push = false) {
|
|
243
|
+
const url = new URL(window.location.href);
|
|
244
|
+
const values = {
|
|
245
|
+
view: state.view === "overview" ? "" : state.view,
|
|
246
|
+
q: state.search,
|
|
247
|
+
status: state.status === "all" ? "" : state.status,
|
|
248
|
+
spec: state.spec === "all" ? "" : state.spec,
|
|
249
|
+
tag: state.tag === "all" ? "" : state.tag,
|
|
250
|
+
flaky: state.flaky === "all" ? "" : state.flaky,
|
|
251
|
+
sort: state.sort === "severity" ? "" : state.sort,
|
|
252
|
+
runs: state.trendLimit === 20 ? "" : state.trendLimit,
|
|
253
|
+
test: state.selectedId || "",
|
|
254
|
+
};
|
|
255
|
+
for (const [key, value] of Object.entries(values)) {
|
|
256
|
+
if (value) url.searchParams.set(key, value);
|
|
257
|
+
else url.searchParams.delete(key);
|
|
258
|
+
}
|
|
259
|
+
history[push ? "pushState" : "replaceState"](null, "", url);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function savePreferences() {
|
|
263
|
+
localStorage.setItem(preferenceStorageKey, JSON.stringify({
|
|
264
|
+
density: state.density,
|
|
265
|
+
theme: state.theme,
|
|
266
|
+
sidebarCollapsed: state.sidebarCollapsed,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyAppearance() {
|
|
271
|
+
document.documentElement.dataset.theme = state.theme;
|
|
272
|
+
document.documentElement.dataset.density = state.density;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const escapeHtml = (value) =>
|
|
276
|
+
String(value ?? "")
|
|
277
|
+
.replaceAll("&", "&")
|
|
278
|
+
.replaceAll("<", "<")
|
|
279
|
+
.replaceAll(">", ">")
|
|
280
|
+
.replaceAll('"', """)
|
|
281
|
+
.replaceAll("'", "'");
|
|
282
|
+
|
|
283
|
+
const formatLogMessage = (value) => {
|
|
284
|
+
const message = String(value ?? "");
|
|
285
|
+
const highlightPattern = /\*\*([\s\S]+?)\*\*/g;
|
|
286
|
+
let formatted = "";
|
|
287
|
+
let lastIndex = 0;
|
|
288
|
+
let match;
|
|
289
|
+
|
|
290
|
+
while ((match = highlightPattern.exec(message)) !== null) {
|
|
291
|
+
formatted += escapeHtml(message.slice(lastIndex, match.index));
|
|
292
|
+
formatted += `<strong class="logHighlight">${escapeHtml(match[1])}</strong>`;
|
|
293
|
+
lastIndex = match.index + match[0].length;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
formatted += escapeHtml(message.slice(lastIndex));
|
|
297
|
+
return formatted;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function stepWordCharacterLimit() {
|
|
301
|
+
if (window.innerWidth <= 520) return 24;
|
|
302
|
+
if (window.innerWidth <= 900) return 32;
|
|
303
|
+
if (window.innerWidth <= 1400) return 44;
|
|
304
|
+
return 56;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function truncateStepWords(value) {
|
|
308
|
+
const fullText = String(value || "");
|
|
309
|
+
const maximum = stepWordCharacterLimit();
|
|
310
|
+
const text = fullText.replace(/\S+/gu, (word) =>
|
|
311
|
+
word.length > maximum
|
|
312
|
+
? `${word.slice(0, maximum - 3)}...`
|
|
313
|
+
: word);
|
|
314
|
+
return {
|
|
315
|
+
fullText,
|
|
316
|
+
text,
|
|
317
|
+
truncated: text !== fullText,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const formatDuration = (milliseconds) => {
|
|
322
|
+
const value = Math.max(0, Number(milliseconds || 0));
|
|
323
|
+
const rounded = (number) =>
|
|
324
|
+
Math.round((number + Number.EPSILON) * 100) / 100;
|
|
325
|
+
if (value < 1000) return `${rounded(value)} ms`;
|
|
326
|
+
const seconds = value / 1000;
|
|
327
|
+
if (seconds < 60) return `${rounded(seconds)} s`;
|
|
328
|
+
const minutes = Math.floor(seconds / 60);
|
|
329
|
+
return `${minutes}m ${rounded(seconds % 60)}s`;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const formatDate = (value, compact = false) => {
|
|
333
|
+
if (!value) return "Sin fecha";
|
|
334
|
+
const date = new Date(value);
|
|
335
|
+
return new Intl.DateTimeFormat("es-AR", compact
|
|
336
|
+
? { day: "2-digit", month: "2-digit" }
|
|
337
|
+
: {
|
|
338
|
+
day: "2-digit",
|
|
339
|
+
month: "short",
|
|
340
|
+
year: "numeric",
|
|
341
|
+
hour: "2-digit",
|
|
342
|
+
minute: "2-digit",
|
|
343
|
+
}).format(date);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const formatTimelineTime = (value) => {
|
|
347
|
+
const timestamp = Date.parse(value || "");
|
|
348
|
+
if (!Number.isFinite(timestamp)) return "";
|
|
349
|
+
return new Intl.DateTimeFormat("es-AR", {
|
|
350
|
+
hour: "2-digit",
|
|
351
|
+
minute: "2-digit",
|
|
352
|
+
second: "2-digit",
|
|
353
|
+
fractionalSecondDigits: 3,
|
|
354
|
+
hour12: false,
|
|
355
|
+
}).format(new Date(timestamp));
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const statusLabel = {
|
|
359
|
+
passed: "Exitoso",
|
|
360
|
+
failed: "Fallido",
|
|
361
|
+
skipped: "Omitido",
|
|
362
|
+
pending: "Pendiente",
|
|
363
|
+
unknown: "Desconocido",
|
|
364
|
+
...customErrorStatuses,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const annotationFor = (test) => state.annotations[test.id] || {};
|
|
368
|
+
|
|
369
|
+
const effectiveTestStatus = (test) => {
|
|
370
|
+
const annotatedStatus = annotationFor(test).status;
|
|
371
|
+
return persistedAnnotationStatuses.has(annotatedStatus)
|
|
372
|
+
? annotatedStatus
|
|
373
|
+
: test.status;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const unique = (items) => [...new Set(items.filter(Boolean))].sort();
|
|
377
|
+
const specs = unique(run.tests.map((test) => test.spec));
|
|
378
|
+
const tags = unique(run.tests.flatMap((test) => test.tags || []));
|
|
379
|
+
const jiraIdForTest = (test) => {
|
|
380
|
+
for (const tag of test.tags || []) {
|
|
381
|
+
const match = String(tag || "").match(/^@?([A-Z][A-Z0-9]+-\d+)$/i);
|
|
382
|
+
if (match) return match[1].toUpperCase();
|
|
383
|
+
}
|
|
384
|
+
return "";
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const originalTestTitle = (test) =>
|
|
388
|
+
test.originalTitle ||
|
|
389
|
+
String(test.title || "").replace(/\s+\(example\s+#\d+\)\s*$/i, "");
|
|
390
|
+
|
|
391
|
+
const testCaseKey = (test) =>
|
|
392
|
+
test.caseId || [test.spec, test.suite, originalTestTitle(test)].join("\u001f");
|
|
393
|
+
|
|
394
|
+
function groupTestsByCase(tests) {
|
|
395
|
+
const groups = new Map();
|
|
396
|
+
|
|
397
|
+
for (const test of tests) {
|
|
398
|
+
const key = testCaseKey(test);
|
|
399
|
+
if (!groups.has(key)) {
|
|
400
|
+
groups.set(key, {
|
|
401
|
+
id: key,
|
|
402
|
+
title: originalTestTitle(test),
|
|
403
|
+
spec: test.spec,
|
|
404
|
+
tests: [],
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
groups.get(key).tests.push(test);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return [...groups.values()];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function aggregateTestStatus(tests) {
|
|
414
|
+
const statuses = tests.map(effectiveTestStatus);
|
|
415
|
+
if (statuses.includes("failed")) return "failed";
|
|
416
|
+
const customStatus = statuses.find((status) => customErrorStatuses[status]);
|
|
417
|
+
if (customStatus) return customStatus;
|
|
418
|
+
if (statuses.includes("pending")) return "pending";
|
|
419
|
+
if (statuses.includes("skipped")) return "skipped";
|
|
420
|
+
if (statuses.every((status) => status === "passed")) return "passed";
|
|
421
|
+
return "unknown";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function filteredTests() {
|
|
425
|
+
const rawQuery = state.search.trim();
|
|
426
|
+
const tokens = rawQuery.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
427
|
+
const operators = {};
|
|
428
|
+
const plainTerms = [];
|
|
429
|
+
for (const token of tokens) {
|
|
430
|
+
const match = token.match(/^(jira|status|tag|ticket|spec):(.+)$/i);
|
|
431
|
+
if (match) operators[match[1].toLowerCase()] = match[2].replaceAll('"', "").toLowerCase();
|
|
432
|
+
else plainTerms.push(token.replaceAll('"', "").toLowerCase());
|
|
433
|
+
}
|
|
434
|
+
const severity = {
|
|
435
|
+
failed: 0,
|
|
436
|
+
reported: 1,
|
|
437
|
+
environment_error: 2,
|
|
438
|
+
precondition_error: 3,
|
|
439
|
+
outdated_test: 4,
|
|
440
|
+
pending: 5,
|
|
441
|
+
skipped: 6,
|
|
442
|
+
passed: 7,
|
|
443
|
+
};
|
|
444
|
+
const filtered = run.tests.filter((test) => {
|
|
445
|
+
const effectiveStatus = effectiveTestStatus(test);
|
|
446
|
+
const annotation = annotationFor(test);
|
|
447
|
+
const jiraId = jiraIdForTest(test).toLowerCase();
|
|
448
|
+
const haystack = [
|
|
449
|
+
test.title,
|
|
450
|
+
originalTestTitle(test),
|
|
451
|
+
test.suite,
|
|
452
|
+
test.spec,
|
|
453
|
+
...(test.tags || []),
|
|
454
|
+
jiraIdForTest(test),
|
|
455
|
+
annotation.comment,
|
|
456
|
+
annotation.ticket,
|
|
457
|
+
].join(" ").toLowerCase();
|
|
458
|
+
return (
|
|
459
|
+
plainTerms.every((term) => haystack.includes(term)) &&
|
|
460
|
+
(!operators.jira || jiraId.includes(operators.jira)) &&
|
|
461
|
+
(!operators.status || effectiveStatus.includes(operators.status)) &&
|
|
462
|
+
(!operators.tag || (test.tags || []).some((tag) =>
|
|
463
|
+
tag.toLowerCase().includes(operators.tag))) &&
|
|
464
|
+
(!operators.ticket || String(annotation.ticket || "").toLowerCase()
|
|
465
|
+
.includes(operators.ticket)) &&
|
|
466
|
+
(!operators.spec || test.spec.toLowerCase().includes(operators.spec)) &&
|
|
467
|
+
(state.attentionFilter !== "no_comment" || !annotation.comment) &&
|
|
468
|
+
(state.attentionFilter !== "no_ticket" || !annotation.ticket) &&
|
|
469
|
+
(
|
|
470
|
+
state.status === "all" ||
|
|
471
|
+
effectiveStatus === state.status ||
|
|
472
|
+
(state.status === "other_errors" && otherErrorStatuses.has(effectiveStatus))
|
|
473
|
+
) &&
|
|
474
|
+
(state.spec === "all" || test.spec === state.spec) &&
|
|
475
|
+
(state.tag === "all" || (test.tags || []).includes(state.tag)) &&
|
|
476
|
+
(state.flaky === "all" ||
|
|
477
|
+
(state.flaky === "yes" && test.flaky) ||
|
|
478
|
+
(state.flaky === "no" && !test.flaky))
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
return filtered.sort((left, right) => {
|
|
482
|
+
if (state.sort === "duration") return Number(right.durationMs) - Number(left.durationMs);
|
|
483
|
+
if (state.sort === "name") return originalTestTitle(left)
|
|
484
|
+
.localeCompare(originalTestTitle(right), "es");
|
|
485
|
+
if (state.sort === "history") {
|
|
486
|
+
return Number(right.retries || 0) - Number(left.retries || 0);
|
|
487
|
+
}
|
|
488
|
+
return (severity[effectiveTestStatus(left)] ?? 99) -
|
|
489
|
+
(severity[effectiveTestStatus(right)] ?? 99);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function renderQualityPanel() {
|
|
494
|
+
if (!state.analytics) {
|
|
495
|
+
return `<div class="analyticsLoading">Consultando indicadores de calidad…</div>`;
|
|
496
|
+
}
|
|
497
|
+
const unstable = state.analytics.unstable || [];
|
|
498
|
+
const recurrent = state.analytics.recurrentFailures || [];
|
|
499
|
+
const slowest = state.analytics.slowest || [];
|
|
500
|
+
const sparkline = (history = []) => {
|
|
501
|
+
const recentHistory = history.slice(0, 5);
|
|
502
|
+
return `<span class="sparkline" aria-label="Últimas ${recentHistory.length} ejecuciones">
|
|
503
|
+
${recentHistory.map((event) => `<i
|
|
504
|
+
class="${event.flaky ? "flaky" : escapeHtml(event.status)}"
|
|
505
|
+
title="${event.flaky ? "Flaky" : statusLabel[event.status] || event.status}"
|
|
506
|
+
></i>`).join("")}
|
|
507
|
+
</span>`;
|
|
508
|
+
};
|
|
509
|
+
const datasets = {
|
|
510
|
+
flaky: {
|
|
511
|
+
title: "Pruebas inestables",
|
|
512
|
+
description: "Fallaron en un intento y terminaron exitosas después de un retry.",
|
|
513
|
+
items: unstable,
|
|
514
|
+
metric: (item) => `${item.flaky_runs} corrida${item.flaky_runs === 1 ? "" : "s"} flaky`,
|
|
515
|
+
empty: "No se detectaron pruebas flaky.",
|
|
516
|
+
},
|
|
517
|
+
recurrent: {
|
|
518
|
+
title: "Fallos recurrentes",
|
|
519
|
+
description: "Pruebas que finalizaron fallidas en una o más corridas.",
|
|
520
|
+
items: recurrent,
|
|
521
|
+
metric: (item) => item.sample_sufficient
|
|
522
|
+
? `${item.failure_rate}% de fallos`
|
|
523
|
+
: `${item.failures} fallo${item.failures === 1 ? "" : "s"} · datos insuficientes`,
|
|
524
|
+
empty: "No hay fallos recurrentes.",
|
|
525
|
+
},
|
|
526
|
+
slow: {
|
|
527
|
+
title: "Pruebas más lentas",
|
|
528
|
+
description: "Promedio histórico de duración por prueba.",
|
|
529
|
+
items: slowest,
|
|
530
|
+
metric: (item) => formatDuration(item.average_duration),
|
|
531
|
+
empty: "No hay información de duración.",
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
const selected = datasets[state.qualityTab] || datasets.flaky;
|
|
535
|
+
return `<div class="qualityTabs" role="tablist" aria-label="Indicadores históricos">
|
|
536
|
+
${Object.entries({
|
|
537
|
+
flaky: `Inestables (${unstable.length})`,
|
|
538
|
+
recurrent: `Fallos recurrentes (${recurrent.length})`,
|
|
539
|
+
slow: "Más lentas",
|
|
540
|
+
}).map(([key, label]) => `<button
|
|
541
|
+
type="button"
|
|
542
|
+
role="tab"
|
|
543
|
+
aria-selected="${state.qualityTab === key}"
|
|
544
|
+
data-quality-tab="${key}"
|
|
545
|
+
>${escapeHtml(label)}</button>`).join("")}
|
|
546
|
+
</div>
|
|
547
|
+
<section class="qualityContent" role="tabpanel">
|
|
548
|
+
<header><h3>${escapeHtml(selected.title)}</h3><p>${escapeHtml(selected.description)}</p></header>
|
|
549
|
+
<div class="qualityList">
|
|
550
|
+
${selected.items.length
|
|
551
|
+
? selected.items.slice(0, 10).map((item) => `<article class="qualityRow">
|
|
552
|
+
<span>
|
|
553
|
+
<strong>${escapeHtml(item.title)}</strong>
|
|
554
|
+
<small>${escapeHtml(item.executions)} ejecuciones · ${escapeHtml(item.spec)}</small>
|
|
555
|
+
</span>
|
|
556
|
+
${sparkline(item.history)}
|
|
557
|
+
<strong class="qualityMetric">${escapeHtml(selected.metric(item))}</strong>
|
|
558
|
+
</article>`).join("")
|
|
559
|
+
: `<div class="emptyState compact"><strong>${escapeHtml(selected.empty)}</strong></div>`}
|
|
560
|
+
</div>
|
|
561
|
+
</section>`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function bindQualityEvents() {
|
|
565
|
+
document.querySelectorAll("[data-quality-tab]").forEach((button) => {
|
|
566
|
+
button.addEventListener("click", () => {
|
|
567
|
+
state.qualityTab = button.dataset.qualityTab;
|
|
568
|
+
const container = document.getElementById("quality-content");
|
|
569
|
+
if (container) container.innerHTML = renderQualityPanel();
|
|
570
|
+
bindQualityEvents();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function loadAnalytics() {
|
|
576
|
+
try {
|
|
577
|
+
state.analytics = await requestElmuloJson(
|
|
578
|
+
`/api/analytics?environment=${encodeURIComponent(state.trendEnvironment)}`,
|
|
579
|
+
);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
state.analytics = {
|
|
582
|
+
unstable: [],
|
|
583
|
+
recurrentFailures: [],
|
|
584
|
+
slowest: [],
|
|
585
|
+
error: error.message,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const container = document.getElementById("quality-content");
|
|
589
|
+
if (container) container.innerHTML = renderQualityPanel();
|
|
590
|
+
bindQualityEvents();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function compareSelectedRuns() {
|
|
594
|
+
const base = document.getElementById("compare-base")?.value;
|
|
595
|
+
const target = document.getElementById("compare-target")?.value;
|
|
596
|
+
const output = document.getElementById("comparison-results");
|
|
597
|
+
if (!base || !target || base === target) {
|
|
598
|
+
output.innerHTML = `<div class="modalMessage error">Elegí dos corridas diferentes.</div>`;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
output.innerHTML = `<div class="analyticsLoading">Comparando corridas…</div>`;
|
|
602
|
+
try {
|
|
603
|
+
state.comparison = await requestElmuloJson(
|
|
604
|
+
`/api/compare?base=${encodeURIComponent(base)}&target=${encodeURIComponent(target)}`,
|
|
605
|
+
);
|
|
606
|
+
const labels = {
|
|
607
|
+
added: "Agregada",
|
|
608
|
+
removed: "Eliminada",
|
|
609
|
+
new_failure: "Nuevo fallo",
|
|
610
|
+
resolved: "Resuelta",
|
|
611
|
+
status_changed: "Cambió de estado",
|
|
612
|
+
slower: "Más lenta",
|
|
613
|
+
};
|
|
614
|
+
const counts = state.comparison.changes.reduce((summary, change) => {
|
|
615
|
+
summary[change.type] = (summary[change.type] || 0) + 1;
|
|
616
|
+
return summary;
|
|
617
|
+
}, {});
|
|
618
|
+
output.innerHTML = state.comparison.changes.length
|
|
619
|
+
? `<div class="comparisonSummary">
|
|
620
|
+
${Object.entries(counts).map(([type, count]) => `<button
|
|
621
|
+
type="button"
|
|
622
|
+
data-comparison-filter="${escapeHtml(type)}"
|
|
623
|
+
><strong>${escapeHtml(count)}</strong><span>${escapeHtml(labels[type] || type)}</span></button>`).join("")}
|
|
624
|
+
</div>
|
|
625
|
+
<div class="comparisonRows">
|
|
626
|
+
${state.comparison.changes.map((change) => `<article class="comparisonRow ${escapeHtml(change.type)}" data-comparison-type="${escapeHtml(change.type)}">
|
|
627
|
+
<span><strong>${escapeHtml(change.after?.title || change.before?.title)}</strong><small>${escapeHtml(change.after?.spec || change.before?.spec)}</small></span>
|
|
628
|
+
<span>${escapeHtml(labels[change.type] || change.type)}</span>
|
|
629
|
+
</article>`).join("")}
|
|
630
|
+
</div>`
|
|
631
|
+
: `<div class="emptyState"><strong>Sin diferencias relevantes</strong><span>Las corridas seleccionadas son equivalentes.</span></div>`;
|
|
632
|
+
output.querySelectorAll("[data-comparison-filter]").forEach((button) => {
|
|
633
|
+
button.addEventListener("click", () => {
|
|
634
|
+
const type = button.dataset.comparisonFilter;
|
|
635
|
+
output.querySelectorAll("[data-comparison-type]").forEach((row) => {
|
|
636
|
+
row.hidden = row.dataset.comparisonType !== type;
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
} catch (error) {
|
|
641
|
+
output.innerHTML = `<div class="modalMessage error">${escapeHtml(error.message)}</div>`;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function applyBulkTriage() {
|
|
646
|
+
const tests = run.tests.filter((test) => state.bulkSelected.has(test.id));
|
|
647
|
+
const status = document.getElementById("bulk-status")?.value;
|
|
648
|
+
const feedback = document.getElementById("bulk-feedback");
|
|
649
|
+
if (!tests.length || !status) return;
|
|
650
|
+
document.getElementById("bulk-confirmation-modal")?.close();
|
|
651
|
+
feedback.textContent = "Aplicando clasificación…";
|
|
652
|
+
state.persistenceStatus = "saving";
|
|
653
|
+
renderSaveStatus();
|
|
654
|
+
try {
|
|
655
|
+
const result = await requestElmuloJson("/api/bulk-annotations", {
|
|
656
|
+
method: "POST",
|
|
657
|
+
headers: { "Content-Type": "application/json" },
|
|
658
|
+
body: JSON.stringify({
|
|
659
|
+
runId: run.id,
|
|
660
|
+
testIds: tests.map((test) => test.id),
|
|
661
|
+
status,
|
|
662
|
+
comment: document.getElementById("bulk-comment")?.value || "",
|
|
663
|
+
ticket: document.getElementById("bulk-ticket")?.value || "",
|
|
664
|
+
actor: state.actor,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
state.annotations = result.annotations;
|
|
668
|
+
run.annotations = result.annotations;
|
|
669
|
+
run.trends = result.trends;
|
|
670
|
+
feedback.textContent = `${result.updated} ejecuciones actualizadas.`;
|
|
671
|
+
state.bulkSelected.clear();
|
|
672
|
+
state.persistenceStatus = "saved";
|
|
673
|
+
renderSaveStatus();
|
|
674
|
+
renderList();
|
|
675
|
+
renderTrendArea();
|
|
676
|
+
} catch (error) {
|
|
677
|
+
feedback.textContent = error.message;
|
|
678
|
+
state.persistenceStatus = "error";
|
|
679
|
+
renderSaveStatus(error.message);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function openBulkConfirmation() {
|
|
684
|
+
const dialog = document.getElementById("bulk-confirmation-modal");
|
|
685
|
+
const count = state.bulkSelected.size;
|
|
686
|
+
const status = document.getElementById("bulk-status")?.value;
|
|
687
|
+
if (!dialog || !count || !status) return;
|
|
688
|
+
dialog.innerHTML = `<div class="confirmationShell">
|
|
689
|
+
<header>
|
|
690
|
+
<div><p class="eyebrow">Confirmar modificación masiva</p><h2>¿Aplicar el cambio a ${count} ${count === 1 ? "ejecución" : "ejecuciones"}?</h2></div>
|
|
691
|
+
<button class="modalCloseButton" type="button" data-close-bulk aria-label="Cerrar">×</button>
|
|
692
|
+
</header>
|
|
693
|
+
<div class="confirmationBody">
|
|
694
|
+
<p>Se aplicará la clasificación <strong>${escapeHtml(statusLabel[status] || status)}</strong> únicamente a las pruebas seleccionadas.</p>
|
|
695
|
+
<p>El cambio quedará registrado con el analista <strong>${escapeHtml(state.actor)}</strong>.</p>
|
|
696
|
+
</div>
|
|
697
|
+
<footer>
|
|
698
|
+
<button class="cancelReuseButton" type="button" data-close-bulk>Cancelar</button>
|
|
699
|
+
<button class="confirmReuseButton" type="button" data-confirm-bulk>Confirmar ${count} ${count === 1 ? "cambio" : "cambios"}</button>
|
|
700
|
+
</footer>
|
|
701
|
+
</div>`;
|
|
702
|
+
dialog.querySelectorAll("[data-close-bulk]").forEach((button) =>
|
|
703
|
+
button.addEventListener("click", () => dialog.close()));
|
|
704
|
+
dialog.querySelector("[data-confirm-bulk]")?.addEventListener("click", applyBulkTriage);
|
|
705
|
+
dialog.showModal();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function metric(label, value, className = "") {
|
|
709
|
+
return `<article class="metricCard ${className}">
|
|
710
|
+
<span>${escapeHtml(label)}</span>
|
|
711
|
+
<strong>${value}</strong>
|
|
712
|
+
</article>`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function summaryMarkup() {
|
|
716
|
+
const effectiveStatuses = run.tests.map(effectiveTestStatus);
|
|
717
|
+
const statusCount = (status) =>
|
|
718
|
+
effectiveStatuses.filter((value) => value === status).length;
|
|
719
|
+
const otherErrorsCount = effectiveStatuses.filter((status) =>
|
|
720
|
+
otherErrorStatuses.has(status)).length;
|
|
721
|
+
const activeClass = (status, baseClass = status) =>
|
|
722
|
+
`${baseClass} ${state.status === status ? "activeFilter" : ""}`.trim();
|
|
723
|
+
const previousRun = [...(run.trends?.runs || [])]
|
|
724
|
+
.reverse()
|
|
725
|
+
.find((item) => item.id !== run.id);
|
|
726
|
+
const delta = (current, previous) => {
|
|
727
|
+
if (!previousRun || previous == null) return "";
|
|
728
|
+
const difference = Number(current) - Number(previous || 0);
|
|
729
|
+
if (!difference) return '<small class="metricDelta neutral">Sin cambios</small>';
|
|
730
|
+
return `<small class="metricDelta ${difference > 0 ? "up" : "down"}">${difference > 0 ? "+" : ""}${difference} vs. anterior</small>`;
|
|
731
|
+
};
|
|
732
|
+
const passedCount = statusCount("passed");
|
|
733
|
+
const failedCount = statusCount("failed");
|
|
734
|
+
const previousOtherErrors = previousRun
|
|
735
|
+
? Number(previousRun.environment_error || 0) +
|
|
736
|
+
Number(previousRun.precondition_error || 0) +
|
|
737
|
+
Number(previousRun.outdated_test || 0) +
|
|
738
|
+
Number(previousRun.reported || 0)
|
|
739
|
+
: null;
|
|
740
|
+
|
|
741
|
+
return `
|
|
742
|
+
${metric("Casos", groupTestsByCase(run.tests).length)}
|
|
743
|
+
${metric("Ejecuciones", run.tests.length)}
|
|
744
|
+
${metric("Exitosas", `${passedCount}${delta(passedCount, previousRun?.passed)}`, activeClass("passed"))}
|
|
745
|
+
${metric("Fallidas", `${failedCount}${delta(failedCount, previousRun?.failed)}`, activeClass("failed"))}
|
|
746
|
+
${metric(
|
|
747
|
+
"Otros errores",
|
|
748
|
+
`${otherErrorsCount}${delta(otherErrorsCount, previousOtherErrors)}`,
|
|
749
|
+
activeClass("other_errors", "otherErrors"),
|
|
750
|
+
)}
|
|
751
|
+
${metric("Inestables", run.tests.filter((test) => test.flaky).length, "flaky")}
|
|
752
|
+
`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function executionStatusChartMarkup() {
|
|
756
|
+
const effectiveStatuses = run.tests.map(effectiveTestStatus);
|
|
757
|
+
const total = effectiveStatuses.length;
|
|
758
|
+
const definitions = [
|
|
759
|
+
["passed", "Exitosas", "#1a9b78"],
|
|
760
|
+
["failed", "Fallidas", "#ef4161"],
|
|
761
|
+
["environment_error", "Error de ambiente", "#f59e0b"],
|
|
762
|
+
["precondition_error", "Error de precondición", "#ea6b18"],
|
|
763
|
+
["outdated_test", "Pruebas desactualizadas", "#7c5ce5"],
|
|
764
|
+
["reported", "Reportadas", "#1e70b8"],
|
|
765
|
+
["omitted", "Omitidas", "#8da0b3"],
|
|
766
|
+
["other", "Otros estados", "#53677b"],
|
|
767
|
+
];
|
|
768
|
+
const knownStatuses = new Set([
|
|
769
|
+
"passed",
|
|
770
|
+
"failed",
|
|
771
|
+
"environment_error",
|
|
772
|
+
"precondition_error",
|
|
773
|
+
"outdated_test",
|
|
774
|
+
"reported",
|
|
775
|
+
"skipped",
|
|
776
|
+
"pending",
|
|
777
|
+
]);
|
|
778
|
+
const entries = definitions.map(([key, label, color]) => {
|
|
779
|
+
const count = key === "omitted"
|
|
780
|
+
? effectiveStatuses.filter((status) => ["skipped", "pending"].includes(status)).length
|
|
781
|
+
: key === "other"
|
|
782
|
+
? effectiveStatuses.filter((status) => !knownStatuses.has(status)).length
|
|
783
|
+
: effectiveStatuses.filter((status) => status === key).length;
|
|
784
|
+
return {
|
|
785
|
+
key,
|
|
786
|
+
label,
|
|
787
|
+
color,
|
|
788
|
+
count,
|
|
789
|
+
percentage: total ? Math.round((count / total) * 1000) / 10 : 0,
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
let cursor = 0;
|
|
793
|
+
const segments = entries
|
|
794
|
+
.filter((entry) => entry.count > 0)
|
|
795
|
+
.map((entry) => {
|
|
796
|
+
const start = cursor;
|
|
797
|
+
cursor += (entry.count / total) * 100;
|
|
798
|
+
return `${entry.color} ${start}% ${cursor}%`;
|
|
799
|
+
});
|
|
800
|
+
const gradient = segments.length
|
|
801
|
+
? `conic-gradient(${segments.join(",")})`
|
|
802
|
+
: "conic-gradient(#d7e0ea 0% 100%)";
|
|
803
|
+
const ariaLabel = [
|
|
804
|
+
`${total} ejecuciones`,
|
|
805
|
+
...entries.filter((entry) => entry.count > 0)
|
|
806
|
+
.map((entry) => `${entry.label}: ${entry.count}`),
|
|
807
|
+
].join(". ");
|
|
808
|
+
|
|
809
|
+
return `<div class="statusDistributionCopy">
|
|
810
|
+
<p class="eyebrow">Corrida actual</p>
|
|
811
|
+
<h2>Ejecuciones por estado</h2>
|
|
812
|
+
<p>La clasificación manual se refleja junto con el resultado automático.</p>
|
|
813
|
+
</div>
|
|
814
|
+
<div class="statusDonutWrap">
|
|
815
|
+
<div
|
|
816
|
+
class="statusDonut"
|
|
817
|
+
style="--donut-gradient:${gradient}"
|
|
818
|
+
role="img"
|
|
819
|
+
aria-label="${escapeHtml(ariaLabel)}"
|
|
820
|
+
>
|
|
821
|
+
<span><strong>${escapeHtml(total)}</strong><small>Ejecuciones</small></span>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
<div class="statusDistributionLegend" aria-label="Referencias del gráfico">
|
|
825
|
+
${entries.map((entry) => `<div class="statusLegendItem ${entry.count ? "" : "empty"}" style="--status-color:${entry.color}">
|
|
826
|
+
<span class="statusLegendDot" aria-hidden="true"></span>
|
|
827
|
+
<span><strong>${escapeHtml(entry.label)}</strong><small>${escapeHtml(entry.percentage)}%</small></span>
|
|
828
|
+
<b>${escapeHtml(entry.count)}</b>
|
|
829
|
+
</div>`).join("")}
|
|
830
|
+
</div>`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function renderSummary() {
|
|
834
|
+
const summary = document.getElementById("summary-grid");
|
|
835
|
+
if (summary) summary.innerHTML = summaryMarkup();
|
|
836
|
+
const chart = document.getElementById("execution-status-chart");
|
|
837
|
+
if (chart) chart.innerHTML = executionStatusChartMarkup();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function featureDistributionMarkup() {
|
|
841
|
+
const definitions = [
|
|
842
|
+
["passed", "Exitosos"],
|
|
843
|
+
["failed", "Fallidos"],
|
|
844
|
+
["environment_error", "Error de ambiente"],
|
|
845
|
+
["precondition_error", "Error de precondición"],
|
|
846
|
+
["outdated_test", "Prueba desactualizada"],
|
|
847
|
+
["reported", "Reportado"],
|
|
848
|
+
["omitted", "Omitidos"],
|
|
849
|
+
["other", "Otros"],
|
|
850
|
+
];
|
|
851
|
+
const knownStatuses = new Set(definitions.slice(0, 6).map(([status]) => status));
|
|
852
|
+
const groups = new Map();
|
|
853
|
+
for (const test of run.tests) {
|
|
854
|
+
const feature = test.feature || test.suite || test.spec?.split("/").at(-1) || "Sin Feature";
|
|
855
|
+
if (!groups.has(feature)) {
|
|
856
|
+
groups.set(feature, {
|
|
857
|
+
feature,
|
|
858
|
+
counts: Object.fromEntries(definitions.map(([status]) => [status, 0])),
|
|
859
|
+
total: 0,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const group = groups.get(feature);
|
|
863
|
+
const effectiveStatus = effectiveTestStatus(test);
|
|
864
|
+
const status = ["skipped", "pending"].includes(effectiveStatus)
|
|
865
|
+
? "omitted"
|
|
866
|
+
: knownStatuses.has(effectiveStatus)
|
|
867
|
+
? effectiveStatus
|
|
868
|
+
: "other";
|
|
869
|
+
group.counts[status] += 1;
|
|
870
|
+
group.total += 1;
|
|
871
|
+
}
|
|
872
|
+
const features = [...groups.values()]
|
|
873
|
+
.sort((left, right) => left.feature.localeCompare(right.feature, "es"));
|
|
874
|
+
const visibleStatuses = definitions.filter(([status]) =>
|
|
875
|
+
features.some((feature) => feature.counts[status] > 0));
|
|
876
|
+
|
|
877
|
+
if (!features.length) {
|
|
878
|
+
return `<div class="emptyState compact"><strong>No hay Features disponibles en esta corrida.</strong></div>`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return `<div class="featureDistribution">
|
|
882
|
+
<div class="featureLegend" aria-label="Referencias de estados">
|
|
883
|
+
${visibleStatuses.map(([status, label]) =>
|
|
884
|
+
`<span><i class="${status}" aria-hidden="true"></i>${escapeHtml(label)}</span>`).join("")}
|
|
885
|
+
</div>
|
|
886
|
+
<div class="featureRows">
|
|
887
|
+
${features.map((feature) => {
|
|
888
|
+
const ariaLabel = visibleStatuses
|
|
889
|
+
.filter(([status]) => feature.counts[status] > 0)
|
|
890
|
+
.map(([status, label]) => `${label}: ${feature.counts[status]}`)
|
|
891
|
+
.join(". ");
|
|
892
|
+
return `<article class="featureRow">
|
|
893
|
+
<strong title="${escapeHtml(feature.feature)}">${escapeHtml(feature.feature)}</strong>
|
|
894
|
+
<div
|
|
895
|
+
class="featureStatusBar"
|
|
896
|
+
role="img"
|
|
897
|
+
aria-label="${escapeHtml(`${feature.feature}. ${ariaLabel}`)}"
|
|
898
|
+
>
|
|
899
|
+
${visibleStatuses
|
|
900
|
+
.filter(([status]) => feature.counts[status] > 0)
|
|
901
|
+
.map(([status, label]) => `<span
|
|
902
|
+
class="featureStatusSegment ${status}"
|
|
903
|
+
style="--feature-count:${feature.counts[status]}"
|
|
904
|
+
title="${escapeHtml(`${label}: ${feature.counts[status]}`)}"
|
|
905
|
+
>${escapeHtml(feature.counts[status])}</span>`)
|
|
906
|
+
.join("")}
|
|
907
|
+
</div>
|
|
908
|
+
</article>`;
|
|
909
|
+
}).join("")}
|
|
910
|
+
</div>
|
|
911
|
+
</div>`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function attentionMarkup() {
|
|
915
|
+
const failed = run.tests.filter((test) => effectiveTestStatus(test) === "failed");
|
|
916
|
+
const flaky = run.tests.filter((test) => test.flaky);
|
|
917
|
+
const withoutComment = failed.filter((test) => !annotationFor(test).comment);
|
|
918
|
+
const withoutTicket = failed.filter((test) => !annotationFor(test).ticket);
|
|
919
|
+
const reported = run.tests.filter((test) => effectiveTestStatus(test) === "reported");
|
|
920
|
+
const cards = [
|
|
921
|
+
["failed", "Fallos para revisar", failed.length, "Resultado automático fallido"],
|
|
922
|
+
["flaky", "Pruebas inestables", flaky.length, "Fallaron y pasaron en un retry"],
|
|
923
|
+
["no_comment", "Sin análisis", withoutComment.length, "Fallos sin comentario"],
|
|
924
|
+
["no_ticket", "Sin ticket", withoutTicket.length, "Fallos sin bug asociado"],
|
|
925
|
+
["reported", "Reportadas", reported.length, "Con seguimiento abierto"],
|
|
926
|
+
];
|
|
927
|
+
return cards.map(([key, label, value, description]) => `<button
|
|
928
|
+
class="attentionCard ${key}"
|
|
929
|
+
type="button"
|
|
930
|
+
data-attention-filter="${key}"
|
|
931
|
+
${Number(value) === 0 ? "disabled" : ""}
|
|
932
|
+
>
|
|
933
|
+
<span>${escapeHtml(label)}</span>
|
|
934
|
+
<strong>${escapeHtml(value)}</strong>
|
|
935
|
+
<small>${escapeHtml(description)}</small>
|
|
936
|
+
</button>`).join("");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function bindAttentionEvents() {
|
|
940
|
+
document.querySelectorAll("[data-attention-filter]").forEach((button) => {
|
|
941
|
+
button.addEventListener("click", () => {
|
|
942
|
+
const filter = button.dataset.attentionFilter;
|
|
943
|
+
state.attentionFilter = ["no_comment", "no_ticket"].includes(filter) ? filter : "";
|
|
944
|
+
state.search = "";
|
|
945
|
+
state.flaky = filter === "flaky" ? "yes" : "all";
|
|
946
|
+
state.status = ["failed", "reported"].includes(filter) ? filter : "all";
|
|
947
|
+
if (filter === "no_comment") state.search = "status:failed";
|
|
948
|
+
if (filter === "no_ticket") state.search = "status:failed";
|
|
949
|
+
state.view = "tests";
|
|
950
|
+
syncUrlState();
|
|
951
|
+
render();
|
|
952
|
+
document.querySelector(".resultsLayout")?.scrollIntoView({ behavior: "smooth" });
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function renderTrend() {
|
|
958
|
+
const history = run.trends?.runs || [];
|
|
959
|
+
if (!history.length) {
|
|
960
|
+
return `<div class="emptyState"><strong>Primera ejecución</strong><span>Las tendencias aparecerán a partir de la próxima corrida.</span></div>`;
|
|
961
|
+
}
|
|
962
|
+
const trendMetrics = {
|
|
963
|
+
total: {
|
|
964
|
+
label: "Todos",
|
|
965
|
+
axisLabel: "Casos ejecutados",
|
|
966
|
+
className: "total",
|
|
967
|
+
value: (item) => Number(item.total || 0),
|
|
968
|
+
},
|
|
969
|
+
passed: {
|
|
970
|
+
label: "Exitosos",
|
|
971
|
+
axisLabel: "Casos exitosos",
|
|
972
|
+
className: "passed",
|
|
973
|
+
value: (item) => Number(item.passed || 0),
|
|
974
|
+
},
|
|
975
|
+
failed: {
|
|
976
|
+
label: "Fallidos",
|
|
977
|
+
axisLabel: "Casos fallidos",
|
|
978
|
+
className: "failed",
|
|
979
|
+
value: (item) => Number(item.failed || 0),
|
|
980
|
+
},
|
|
981
|
+
environment_error: {
|
|
982
|
+
label: "Errores de ambiente",
|
|
983
|
+
axisLabel: "Errores de ambiente",
|
|
984
|
+
className: "environment_error",
|
|
985
|
+
value: (item) => Number(item.environment_error || 0),
|
|
986
|
+
},
|
|
987
|
+
precondition_error: {
|
|
988
|
+
label: "Errores de precondición",
|
|
989
|
+
axisLabel: "Errores de precondición",
|
|
990
|
+
className: "precondition_error",
|
|
991
|
+
value: (item) => Number(item.precondition_error || 0),
|
|
992
|
+
},
|
|
993
|
+
outdated_test: {
|
|
994
|
+
label: "Pruebas desactualizadas",
|
|
995
|
+
axisLabel: "Pruebas desactualizadas",
|
|
996
|
+
className: "outdated_test",
|
|
997
|
+
value: (item) => Number(item.outdated_test || 0),
|
|
998
|
+
},
|
|
999
|
+
reported: {
|
|
1000
|
+
label: "Reportado",
|
|
1001
|
+
axisLabel: "Casos reportados",
|
|
1002
|
+
className: "reported",
|
|
1003
|
+
value: (item) => Number(item.reported || 0),
|
|
1004
|
+
},
|
|
1005
|
+
success_rate: {
|
|
1006
|
+
label: "Tasa de éxito",
|
|
1007
|
+
axisLabel: "Porcentaje exitoso",
|
|
1008
|
+
className: "passed",
|
|
1009
|
+
value: (item) => Number(item.total)
|
|
1010
|
+
? Math.round((Number(item.passed || 0) / Number(item.total)) * 100)
|
|
1011
|
+
: 0,
|
|
1012
|
+
},
|
|
1013
|
+
};
|
|
1014
|
+
const selectedMetric = trendMetrics[state.trendStatus] || trendMetrics.total;
|
|
1015
|
+
const maxTotal = Math.max(...history.map(selectedMetric.value), 1);
|
|
1016
|
+
const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, maxTotal / 4)));
|
|
1017
|
+
const normalizedStep = maxTotal / 4 / magnitude;
|
|
1018
|
+
const stepMultiplier =
|
|
1019
|
+
normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 5 ? 5 : 10;
|
|
1020
|
+
const tickStep = Math.max(1, stepMultiplier * magnitude);
|
|
1021
|
+
const axisMax = state.trendStatus === "success_rate"
|
|
1022
|
+
? 100
|
|
1023
|
+
: Math.max(tickStep, Math.ceil(maxTotal / tickStep) * tickStep);
|
|
1024
|
+
const ticks = Array.from(
|
|
1025
|
+
{ length: Math.floor(axisMax / tickStep) + 1 },
|
|
1026
|
+
(_, index) => index * tickStep,
|
|
1027
|
+
);
|
|
1028
|
+
const canvasWidth = Math.max(520, history.length * 82);
|
|
1029
|
+
|
|
1030
|
+
return `<div class="trendFigure" aria-label="Tendencia de ${escapeHtml(selectedMetric.axisLabel.toLowerCase())} por corrida">
|
|
1031
|
+
<div class="trendYAxisTitle">${escapeHtml(selectedMetric.axisLabel)}</div>
|
|
1032
|
+
<div class="trendPlot">
|
|
1033
|
+
<div class="trendYAxis" aria-hidden="true">
|
|
1034
|
+
${ticks.map((tick) => `<span style="bottom:${30 + (tick / axisMax) * 198}px">${escapeHtml(tick)}</span>`).join("")}
|
|
1035
|
+
</div>
|
|
1036
|
+
<div class="trendViewport">
|
|
1037
|
+
<div class="trendCanvas" style="min-width:${canvasWidth}px">
|
|
1038
|
+
${ticks.map((tick) => `<i class="trendGridLine ${tick === 0 ? "baseline" : ""}" style="bottom:${30 + (tick / axisMax) * 198}px" aria-hidden="true"></i>`).join("")}
|
|
1039
|
+
<div class="trendColumns" style="--run-count:${history.length}">
|
|
1040
|
+
${history.map((item) => {
|
|
1041
|
+
const total = Number(item.total || 0);
|
|
1042
|
+
const selectedValue = selectedMetric.value(item);
|
|
1043
|
+
const statusSegments = [
|
|
1044
|
+
["passed", Number(item.passed || 0)],
|
|
1045
|
+
["failed", Number(item.failed || 0)],
|
|
1046
|
+
["environment_error", Number(item.environment_error || 0)],
|
|
1047
|
+
["precondition_error", Number(item.precondition_error || 0)],
|
|
1048
|
+
["outdated_test", Number(item.outdated_test || 0)],
|
|
1049
|
+
["reported", Number(item.reported || 0)],
|
|
1050
|
+
];
|
|
1051
|
+
const classified = statusSegments.reduce(
|
|
1052
|
+
(sum, [, value]) => sum + value,
|
|
1053
|
+
0,
|
|
1054
|
+
);
|
|
1055
|
+
const other = Math.max(0, total - classified);
|
|
1056
|
+
const height = selectedValue === 0
|
|
1057
|
+
? 0
|
|
1058
|
+
: Math.max(4, (selectedValue / axisMax) * 100);
|
|
1059
|
+
const percent = (value) => total ? `${(value / total) * 100}%` : "0%";
|
|
1060
|
+
const segments = state.trendStatus === "total"
|
|
1061
|
+
? `${statusSegments
|
|
1062
|
+
.filter(([, value]) => value > 0)
|
|
1063
|
+
.map(([status, value]) =>
|
|
1064
|
+
`<i class="${status}" style="height:${percent(value)}"></i>`)
|
|
1065
|
+
.join("")}
|
|
1066
|
+
${other > 0 ? `<i class="other" style="height:${percent(other)}"></i>` : ""}`
|
|
1067
|
+
: `<i class="${escapeHtml(selectedMetric.className)}" style="height:100%"></i>`;
|
|
1068
|
+
return `<button
|
|
1069
|
+
class="trendColumn ${item.id === state.selectedTrendRunId ? "selected" : ""}"
|
|
1070
|
+
type="button"
|
|
1071
|
+
data-trend-run-id="${escapeHtml(item.id)}"
|
|
1072
|
+
title="${escapeHtml(`${selectedMetric.label}: ${selectedValue}${state.trendStatus === "success_rate" ? "%" : ""} · ${item.total} ejecuciones · ${formatDate(item.started_at)} · ${item.failed || 0} fallidas`)}"
|
|
1073
|
+
aria-label="Consultar corrida del ${escapeHtml(formatDate(item.started_at))}"
|
|
1074
|
+
>
|
|
1075
|
+
<span class="trendValue">${escapeHtml(selectedValue)}${state.trendStatus === "success_rate" ? "%" : ""}</span>
|
|
1076
|
+
<div class="trendBarSlot">
|
|
1077
|
+
<div class="trendBar ${selectedValue === 0 ? "empty" : ""}" style="--bar-height:${height}%">
|
|
1078
|
+
${segments}
|
|
1079
|
+
</div>
|
|
1080
|
+
</div>
|
|
1081
|
+
<small>${escapeHtml(formatDate(item.started_at, true))}</small>
|
|
1082
|
+
</button>`;
|
|
1083
|
+
}).join("")}
|
|
1084
|
+
</div>
|
|
1085
|
+
</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
<div class="trendLegend" aria-label="Leyenda de estados">
|
|
1089
|
+
${[
|
|
1090
|
+
["passed", "Exitosos"],
|
|
1091
|
+
["failed", "Fallidos"],
|
|
1092
|
+
["environment_error", "Error de ambiente"],
|
|
1093
|
+
["precondition_error", "Error de precondición"],
|
|
1094
|
+
["outdated_test", "Prueba desactualizada"],
|
|
1095
|
+
["reported", "Reportado"],
|
|
1096
|
+
].map(([status, label]) => `<span><i class="${status}"></i>${label}</span>`).join("")}
|
|
1097
|
+
</div>
|
|
1098
|
+
<div class="trendXAxisTitle">Corridas</div>
|
|
1099
|
+
</div>`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function renderHistoricalRun() {
|
|
1103
|
+
const historicalRun = state.historicalRun;
|
|
1104
|
+
if (!state.selectedTrendRunId) {
|
|
1105
|
+
return `<div class="trendHistoryPlaceholder">
|
|
1106
|
+
Seleccioná una corrida del gráfico para consultar sus resultados.
|
|
1107
|
+
</div>`;
|
|
1108
|
+
}
|
|
1109
|
+
if (!historicalRun) {
|
|
1110
|
+
return `<div class="trendHistoryPlaceholder">Cargando resultados históricos…</div>`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const tests = historicalRun.tests || [];
|
|
1114
|
+
const statusCount = (status) =>
|
|
1115
|
+
tests.filter((test) => test.status === status).length;
|
|
1116
|
+
const otherErrors = tests.filter((test) =>
|
|
1117
|
+
otherErrorStatuses.has(test.status)).length;
|
|
1118
|
+
const rerun = state.historicalRerun || historicalRun.rerun || {};
|
|
1119
|
+
const rerunBusy = ["starting", "running"].includes(rerun.status);
|
|
1120
|
+
const rerunLabel = rerun.status === "starting"
|
|
1121
|
+
? "Iniciando…"
|
|
1122
|
+
: rerun.status === "running"
|
|
1123
|
+
? "Reejecutando…"
|
|
1124
|
+
: "Reejecutar reporte";
|
|
1125
|
+
const rerunFeedback = {
|
|
1126
|
+
completed: "La reejecución terminó correctamente. Recargá el dashboard para ver la nueva corrida.",
|
|
1127
|
+
failed: rerun.error || "La reejecución no pudo completarse.",
|
|
1128
|
+
unavailable: rerun.reason || "Esta corrida no tiene parámetros reutilizables.",
|
|
1129
|
+
}[rerun.status] || "";
|
|
1130
|
+
|
|
1131
|
+
return `<section class="trendHistoryPanel">
|
|
1132
|
+
<header class="trendHistoryHeader">
|
|
1133
|
+
<div>
|
|
1134
|
+
<p class="eyebrow">Consulta histórica</p>
|
|
1135
|
+
<h3>${escapeHtml(formatDate(historicalRun.started_at))}</h3>
|
|
1136
|
+
<p>${escapeHtml(String(historicalRun.environment || "").toUpperCase())} · ${escapeHtml(historicalRun.tag_expression || "Sin filtro de tags")}</p>
|
|
1137
|
+
</div>
|
|
1138
|
+
<div class="trendHistoryActions">
|
|
1139
|
+
<button
|
|
1140
|
+
class="rerunReportButton"
|
|
1141
|
+
type="button"
|
|
1142
|
+
data-rerun-trend-report
|
|
1143
|
+
${rerunBusy || rerun.available === false ? "disabled" : ""}
|
|
1144
|
+
title="${escapeHtml(rerun.available === false ? rerun.reason : "Ejecuta nuevamente el mismo script, ambiente y expresión de tags")}"
|
|
1145
|
+
>${escapeHtml(rerunLabel)}</button>
|
|
1146
|
+
<span class="environmentPill">${escapeHtml(formatDuration(historicalRun.duration_ms))}</span>
|
|
1147
|
+
<button
|
|
1148
|
+
class="closeHistoryButton"
|
|
1149
|
+
type="button"
|
|
1150
|
+
data-close-trend-history
|
|
1151
|
+
aria-label="Cerrar consulta histórica"
|
|
1152
|
+
>×</button>
|
|
1153
|
+
</div>
|
|
1154
|
+
</header>
|
|
1155
|
+
${rerunFeedback
|
|
1156
|
+
? `<div class="trendRerunFeedback ${rerun.status === "failed" || rerun.status === "unavailable" ? "error" : "success"}" role="status">${escapeHtml(rerunFeedback)}</div>`
|
|
1157
|
+
: ""}
|
|
1158
|
+
<div class="trendHistoryStats">
|
|
1159
|
+
<span><strong>${tests.length}</strong> ejecuciones</span>
|
|
1160
|
+
<span class="passed"><strong>${statusCount("passed")}</strong> exitosas</span>
|
|
1161
|
+
<span class="failed"><strong>${statusCount("failed")}</strong> fallidas</span>
|
|
1162
|
+
<span class="otherErrors"><strong>${otherErrors}</strong> otros errores</span>
|
|
1163
|
+
</div>
|
|
1164
|
+
<div class="historicalTestList">
|
|
1165
|
+
${tests.length
|
|
1166
|
+
? tests.map((test) => `<article class="historicalTestRow">
|
|
1167
|
+
<span class="statusDot ${escapeHtml(test.status)}" aria-hidden="true"></span>
|
|
1168
|
+
<span>
|
|
1169
|
+
<strong>${escapeHtml(test.title)}</strong>
|
|
1170
|
+
<small>${escapeHtml(test.spec)}</small>
|
|
1171
|
+
${test.comment ? `<small class="historicalNote">${escapeHtml(test.comment)}</small>` : ""}
|
|
1172
|
+
${test.ticket ? `<small class="historicalNote">Ticket: ${escapeHtml(test.ticket)}</small>` : ""}
|
|
1173
|
+
</span>
|
|
1174
|
+
<span class="historicalTestMeta">
|
|
1175
|
+
<span class="statusPill ${escapeHtml(test.status)}">${escapeHtml(statusLabel[test.status] || test.status)}</span>
|
|
1176
|
+
<small>${escapeHtml(formatDuration(test.duration_ms))}</small>
|
|
1177
|
+
</span>
|
|
1178
|
+
</article>`).join("")
|
|
1179
|
+
: `<div class="emptyState"><strong>Sin resultados</strong><span>Esta corrida no tiene pruebas registradas.</span></div>`}
|
|
1180
|
+
</div>
|
|
1181
|
+
</section>`;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function updateTrendRunCount() {
|
|
1185
|
+
const counter = document.getElementById("trend-run-count");
|
|
1186
|
+
const count = Number(
|
|
1187
|
+
run.trends?.totalRuns ?? run.trends?.runs?.length ?? 0,
|
|
1188
|
+
);
|
|
1189
|
+
if (counter) {
|
|
1190
|
+
counter.textContent = `${count} corrida${count === 1 ? "" : "s"}`;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function renderTrendArea() {
|
|
1195
|
+
const trendContent = document.getElementById("trend-content");
|
|
1196
|
+
const trendHistory = document.getElementById("trend-history");
|
|
1197
|
+
if (trendContent) trendContent.innerHTML = renderTrend();
|
|
1198
|
+
if (trendHistory) trendHistory.innerHTML = renderHistoricalRun();
|
|
1199
|
+
updateTrendRunCount();
|
|
1200
|
+
bindTrendEvents();
|
|
1201
|
+
bindTrendHistoryClose();
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function bindTrendHistoryClose() {
|
|
1205
|
+
const closeButton = document.querySelector("[data-close-trend-history]");
|
|
1206
|
+
if (!closeButton) return;
|
|
1207
|
+
closeButton.addEventListener("click", () => {
|
|
1208
|
+
state.selectedTrendRunId = null;
|
|
1209
|
+
state.historicalRun = null;
|
|
1210
|
+
state.historicalRerun = null;
|
|
1211
|
+
renderTrendArea();
|
|
1212
|
+
});
|
|
1213
|
+
document.querySelector("[data-rerun-trend-report]")?.addEventListener(
|
|
1214
|
+
"click",
|
|
1215
|
+
startHistoricalRerun,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function renderTrendHistoryPanel() {
|
|
1220
|
+
const history = document.getElementById("trend-history");
|
|
1221
|
+
if (history) history.innerHTML = renderHistoricalRun();
|
|
1222
|
+
bindTrendHistoryClose();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function pollHistoricalRerun(runId) {
|
|
1226
|
+
window.setTimeout(async () => {
|
|
1227
|
+
if (state.selectedTrendRunId !== runId) return;
|
|
1228
|
+
try {
|
|
1229
|
+
const result = await requestElmuloJson(
|
|
1230
|
+
`/api/runs/${encodeURIComponent(runId)}/rerun`,
|
|
1231
|
+
);
|
|
1232
|
+
if (state.selectedTrendRunId !== runId) return;
|
|
1233
|
+
state.historicalRerun = result;
|
|
1234
|
+
renderTrendHistoryPanel();
|
|
1235
|
+
if (state.historicalRerun.status === "running") {
|
|
1236
|
+
pollHistoricalRerun(runId);
|
|
1237
|
+
}
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
if (state.selectedTrendRunId !== runId) return;
|
|
1240
|
+
state.historicalRerun = {
|
|
1241
|
+
available: true,
|
|
1242
|
+
status: "failed",
|
|
1243
|
+
error: error.message,
|
|
1244
|
+
};
|
|
1245
|
+
renderTrendHistoryPanel();
|
|
1246
|
+
}
|
|
1247
|
+
}, 2_000);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function startHistoricalRerun() {
|
|
1251
|
+
const runId = state.selectedTrendRunId;
|
|
1252
|
+
if (!runId || !state.historicalRun) return;
|
|
1253
|
+
state.historicalRerun = {
|
|
1254
|
+
...(state.historicalRerun || {}),
|
|
1255
|
+
available: true,
|
|
1256
|
+
status: "starting",
|
|
1257
|
+
};
|
|
1258
|
+
renderTrendHistoryPanel();
|
|
1259
|
+
try {
|
|
1260
|
+
const result = await requestElmuloJson(
|
|
1261
|
+
`/api/runs/${encodeURIComponent(runId)}/rerun`,
|
|
1262
|
+
{ method: "POST" },
|
|
1263
|
+
);
|
|
1264
|
+
if (state.selectedTrendRunId !== runId) return;
|
|
1265
|
+
state.historicalRerun = {
|
|
1266
|
+
available: true,
|
|
1267
|
+
...result,
|
|
1268
|
+
};
|
|
1269
|
+
renderTrendHistoryPanel();
|
|
1270
|
+
pollHistoricalRerun(runId);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
if (state.selectedTrendRunId !== runId) return;
|
|
1273
|
+
state.historicalRerun = {
|
|
1274
|
+
available: true,
|
|
1275
|
+
status: "failed",
|
|
1276
|
+
error: error.message,
|
|
1277
|
+
};
|
|
1278
|
+
renderTrendHistoryPanel();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function bindTrendEvents() {
|
|
1283
|
+
document.querySelectorAll("[data-trend-run-id]").forEach((button) => {
|
|
1284
|
+
button.addEventListener("click", async () => {
|
|
1285
|
+
const requestedRunId = button.dataset.trendRunId;
|
|
1286
|
+
state.selectedTrendRunId = requestedRunId;
|
|
1287
|
+
state.historicalRun = null;
|
|
1288
|
+
state.historicalRerun = null;
|
|
1289
|
+
renderTrendArea();
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
const result = await requestElmuloJson(
|
|
1293
|
+
`/api/runs/${encodeURIComponent(requestedRunId)}`,
|
|
1294
|
+
);
|
|
1295
|
+
if (state.selectedTrendRunId !== requestedRunId) return;
|
|
1296
|
+
state.historicalRun = result;
|
|
1297
|
+
state.historicalRerun = result.rerun || null;
|
|
1298
|
+
renderTrendHistoryPanel();
|
|
1299
|
+
if (state.historicalRerun?.status === "running") {
|
|
1300
|
+
pollHistoricalRerun(state.selectedTrendRunId);
|
|
1301
|
+
}
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
if (state.selectedTrendRunId !== requestedRunId) return;
|
|
1304
|
+
const history = document.getElementById("trend-history");
|
|
1305
|
+
if (history) {
|
|
1306
|
+
history.innerHTML = `<div class="trendHistoryPlaceholder error">${escapeHtml(error.message)}</div>`;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function loadTrendEnvironment(environment) {
|
|
1314
|
+
state.trendEnvironment = environment;
|
|
1315
|
+
state.selectedTrendRunId = null;
|
|
1316
|
+
state.historicalRun = null;
|
|
1317
|
+
state.historicalRerun = null;
|
|
1318
|
+
const trendContent = document.getElementById("trend-content");
|
|
1319
|
+
const trendHistory = document.getElementById("trend-history");
|
|
1320
|
+
const trendCounter = document.getElementById("trend-run-count");
|
|
1321
|
+
if (trendCounter) trendCounter.textContent = "Consultando…";
|
|
1322
|
+
if (trendContent) {
|
|
1323
|
+
trendContent.innerHTML = `<div class="trendHistoryPlaceholder">Cargando corridas de ${escapeHtml(environment.toUpperCase())}…</div>`;
|
|
1324
|
+
}
|
|
1325
|
+
if (trendHistory) trendHistory.innerHTML = "";
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
const result = await requestElmuloJson(
|
|
1329
|
+
`/api/trends?environment=${encodeURIComponent(environment)}&limit=${encodeURIComponent(state.trendLimit)}`,
|
|
1330
|
+
);
|
|
1331
|
+
run.trends = result;
|
|
1332
|
+
renderTrendArea();
|
|
1333
|
+
loadAnalytics();
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
if (trendContent) {
|
|
1336
|
+
trendContent.innerHTML = `<div class="trendHistoryPlaceholder error">${escapeHtml(error.message)}</div>`;
|
|
1337
|
+
}
|
|
1338
|
+
if (trendCounter) trendCounter.textContent = "Sin datos";
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function renderTestRow(test, options = {}) {
|
|
1343
|
+
const selected = test.id === state.selectedId ? " selected" : "";
|
|
1344
|
+
const exampleClass = options.example ? " exampleRow" : "";
|
|
1345
|
+
const effectiveStatus = effectiveTestStatus(test);
|
|
1346
|
+
const displayTitle = options.example
|
|
1347
|
+
? `Example ${test.exampleIndex || options.position || ""}`.trim()
|
|
1348
|
+
: test.title;
|
|
1349
|
+
const resolvedTitle = String(test.title || "").replace(
|
|
1350
|
+
/\s+\(example\s+#\d+\)\s*$/i,
|
|
1351
|
+
"",
|
|
1352
|
+
);
|
|
1353
|
+
const subtitle = options.example && resolvedTitle !== originalTestTitle(test)
|
|
1354
|
+
? `${resolvedTitle} · ${test.spec}`
|
|
1355
|
+
: test.spec;
|
|
1356
|
+
|
|
1357
|
+
const classification = annotationFor(test).status;
|
|
1358
|
+
const sparkline = test.flaky
|
|
1359
|
+
? '<span class="miniSignal flaky" title="Falló y pasó en un retry">Flaky</span>'
|
|
1360
|
+
: "";
|
|
1361
|
+
return `<div class="testRowContainer ${state.bulkSelected.has(test.id) ? "bulkSelected" : ""}">
|
|
1362
|
+
<label class="testSelector" title="Seleccionar para modificación masiva">
|
|
1363
|
+
<input type="checkbox" data-select-test="${escapeHtml(test.id)}" ${state.bulkSelected.has(test.id) ? "checked" : ""} />
|
|
1364
|
+
<span class="srOnly">Seleccionar ${escapeHtml(displayTitle)}</span>
|
|
1365
|
+
</label>
|
|
1366
|
+
<button class="testRow${exampleClass}${selected}" data-test-id="${escapeHtml(test.id)}" type="button">
|
|
1367
|
+
<span class="statusDot ${escapeHtml(effectiveStatus)} ${test.flaky ? "flaky" : ""}" aria-hidden="true"></span>
|
|
1368
|
+
<span>
|
|
1369
|
+
<span class="testName">${escapeHtml(displayTitle)}</span>
|
|
1370
|
+
<span class="testSpec">${escapeHtml(subtitle)}</span>
|
|
1371
|
+
<span class="rowSignals">
|
|
1372
|
+
<span class="resultSignal">Resultado: ${escapeHtml(statusLabel[test.status] || test.status)}</span>
|
|
1373
|
+
${classification && classification !== test.status
|
|
1374
|
+
? `<span class="classificationSignal ${escapeHtml(classification)}">Clasificación: ${escapeHtml(statusLabel[classification] || classification)}</span>`
|
|
1375
|
+
: ""}
|
|
1376
|
+
${jiraIdForTest(test) ? `<span>${escapeHtml(jiraIdForTest(test))}</span>` : ""}
|
|
1377
|
+
${annotationFor(test).ticket ? `<span>${escapeHtml(annotationFor(test).ticket)}</span>` : ""}
|
|
1378
|
+
${sparkline}
|
|
1379
|
+
</span>
|
|
1380
|
+
${effectiveStatus !== test.status
|
|
1381
|
+
? `<span class="inlineStatus ${escapeHtml(effectiveStatus)}">${escapeHtml(statusLabel[effectiveStatus])}</span>`
|
|
1382
|
+
: ""}
|
|
1383
|
+
</span>
|
|
1384
|
+
<span class="testMeta">
|
|
1385
|
+
${escapeHtml(formatDuration(test.durationMs))}
|
|
1386
|
+
${test.retries ? `<br>${test.retries} reintento${test.retries === 1 ? "" : "s"}` : ""}
|
|
1387
|
+
</span>
|
|
1388
|
+
</button>
|
|
1389
|
+
</div>`;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function renderTestCaseGroup(group) {
|
|
1393
|
+
const hasExamples = group.tests.some((test) => test.isExample);
|
|
1394
|
+
if (!hasExamples) {
|
|
1395
|
+
return renderTestRow(group.tests[0]);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const expanded = state.expandedExampleGroups.has(group.id);
|
|
1399
|
+
const status = aggregateTestStatus(group.tests);
|
|
1400
|
+
const flaky = group.tests.some((test) => test.flaky);
|
|
1401
|
+
const durationMs = group.tests.reduce(
|
|
1402
|
+
(total, test) => total + Number(test.durationMs || 0),
|
|
1403
|
+
0,
|
|
1404
|
+
);
|
|
1405
|
+
const containsSelected = group.tests.some(
|
|
1406
|
+
(test) => test.id === state.selectedId,
|
|
1407
|
+
);
|
|
1408
|
+
const contentId = `examples-${group.id}`;
|
|
1409
|
+
|
|
1410
|
+
return `<section class="testCaseGroup ${containsSelected ? "containsSelected" : ""}">
|
|
1411
|
+
<button
|
|
1412
|
+
class="testRow testCaseRow"
|
|
1413
|
+
data-example-group="${escapeHtml(group.id)}"
|
|
1414
|
+
type="button"
|
|
1415
|
+
aria-expanded="${expanded}"
|
|
1416
|
+
aria-controls="${escapeHtml(contentId)}"
|
|
1417
|
+
>
|
|
1418
|
+
<span class="groupChevron ${expanded ? "expanded" : ""}" aria-hidden="true">›</span>
|
|
1419
|
+
<span>
|
|
1420
|
+
<span class="testName">${escapeHtml(group.title)}</span>
|
|
1421
|
+
<span class="testSpec">
|
|
1422
|
+
<span class="examplesToggleLabel">${expanded ? "Ocultar" : "Mostrar"} ${group.tests.length} examples</span>
|
|
1423
|
+
· ${escapeHtml(group.spec)}
|
|
1424
|
+
</span>
|
|
1425
|
+
</span>
|
|
1426
|
+
<span class="testMeta">
|
|
1427
|
+
<span class="statusPill ${escapeHtml(status)}">${escapeHtml(statusLabel[status] || status)}</span>
|
|
1428
|
+
<br>${escapeHtml(formatDuration(durationMs))}
|
|
1429
|
+
${flaky ? "<br>Inestable" : ""}
|
|
1430
|
+
</span>
|
|
1431
|
+
</button>
|
|
1432
|
+
<div id="${escapeHtml(contentId)}" class="exampleRows" ${expanded ? "" : "hidden"}>
|
|
1433
|
+
${group.tests
|
|
1434
|
+
.sort((left, right) =>
|
|
1435
|
+
Number(left.exampleIndex || 0) - Number(right.exampleIndex || 0))
|
|
1436
|
+
.map((test, index) =>
|
|
1437
|
+
renderTestRow(test, { example: true, position: index + 1 }))
|
|
1438
|
+
.join("")}
|
|
1439
|
+
</div>
|
|
1440
|
+
</section>`;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function buildTimeline(test, includeLogs) {
|
|
1444
|
+
const steps = (test.steps || []).map((step, index) => ({
|
|
1445
|
+
type: "step",
|
|
1446
|
+
timestamp: step.startedAt || "",
|
|
1447
|
+
sortTime: Date.parse(step.startedAt || ""),
|
|
1448
|
+
order: index * 2,
|
|
1449
|
+
value: step,
|
|
1450
|
+
}));
|
|
1451
|
+
const logs = includeLogs
|
|
1452
|
+
? (test.logs || []).map((log, index) => ({
|
|
1453
|
+
type: "log",
|
|
1454
|
+
timestamp: log.timestamp || "",
|
|
1455
|
+
sortTime: Date.parse(log.timestamp || ""),
|
|
1456
|
+
order: index * 2 + 1,
|
|
1457
|
+
value: log,
|
|
1458
|
+
}))
|
|
1459
|
+
: [];
|
|
1460
|
+
const fallbackCandidates = [
|
|
1461
|
+
...steps.map((event) => event.sortTime),
|
|
1462
|
+
Date.parse(test.attempts?.at(-1)?.startedAt || ""),
|
|
1463
|
+
].filter((timestamp) => Number.isFinite(timestamp));
|
|
1464
|
+
const fallbackStart = fallbackCandidates.length
|
|
1465
|
+
? Math.min(...fallbackCandidates)
|
|
1466
|
+
: 0;
|
|
1467
|
+
|
|
1468
|
+
return [...steps, ...logs]
|
|
1469
|
+
.map((event, index) => ({
|
|
1470
|
+
...event,
|
|
1471
|
+
sortTime: Number.isFinite(event.sortTime)
|
|
1472
|
+
? event.sortTime
|
|
1473
|
+
: (Number.isFinite(fallbackStart) ? fallbackStart : 0) + index,
|
|
1474
|
+
}))
|
|
1475
|
+
.sort((left, right) => left.sortTime - right.sortTime || left.order - right.order);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderCucumberTimeline(test) {
|
|
1479
|
+
if (!test.steps?.length) return "";
|
|
1480
|
+
const debugEnabled = state.debugTestIds.has(test.id);
|
|
1481
|
+
const timeline = buildTimeline(test, debugEnabled);
|
|
1482
|
+
|
|
1483
|
+
return `<section class="detailSection">
|
|
1484
|
+
<header class="timelineHeader">
|
|
1485
|
+
<div>
|
|
1486
|
+
<h3>Pasos Cucumber</h3>
|
|
1487
|
+
<p>${debugEnabled
|
|
1488
|
+
? `${test.logs?.length || 0} logs intercalados cronológicamente`
|
|
1489
|
+
: "Los logs están colapsados"}
|
|
1490
|
+
</p>
|
|
1491
|
+
</div>
|
|
1492
|
+
<button
|
|
1493
|
+
class="debugButton ${debugEnabled ? "active" : ""}"
|
|
1494
|
+
type="button"
|
|
1495
|
+
data-debug-test="${escapeHtml(test.id)}"
|
|
1496
|
+
aria-pressed="${debugEnabled}"
|
|
1497
|
+
title="${debugEnabled ? "Ocultar logs" : "Mostrar logs entre los steps"}"
|
|
1498
|
+
>Debug</button>
|
|
1499
|
+
</header>
|
|
1500
|
+
<ol class="timelineList">
|
|
1501
|
+
${timeline.map((event) => {
|
|
1502
|
+
if (event.type === "log") {
|
|
1503
|
+
const log = event.value;
|
|
1504
|
+
return `<li class="timelineLog">
|
|
1505
|
+
<span class="timelineMarker" aria-hidden="true">›</span>
|
|
1506
|
+
<span class="timelineBody">
|
|
1507
|
+
<strong>${escapeHtml(log.name)}</strong>
|
|
1508
|
+
<span class="logMessage">${formatLogMessage(log.message)}</span>
|
|
1509
|
+
</span>
|
|
1510
|
+
<time datetime="${escapeHtml(log.timestamp)}">${escapeHtml(formatTimelineTime(log.timestamp))}</time>
|
|
1511
|
+
</li>`;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const step = event.value;
|
|
1515
|
+
const stepText = truncateStepWords(step.name);
|
|
1516
|
+
return `<li class="step timelineStep">
|
|
1517
|
+
<span class="timelineMarker ${escapeHtml(step.status)}" aria-hidden="true"></span>
|
|
1518
|
+
<span class="stepKeyword">${escapeHtml(step.keyword)}</span>
|
|
1519
|
+
<span class="timelineBody">
|
|
1520
|
+
${stepText.truncated
|
|
1521
|
+
? `<span class="stepText" title="${escapeHtml(stepText.fullText)}"><span aria-hidden="true">${escapeHtml(stepText.text)}</span><span class="srOnly">${escapeHtml(stepText.fullText)}</span></span>`
|
|
1522
|
+
: `<span class="stepText">${escapeHtml(stepText.fullText)}</span>`}
|
|
1523
|
+
${step.error ? `<pre>${escapeHtml(step.error)}</pre>` : ""}
|
|
1524
|
+
</span>
|
|
1525
|
+
<span class="stepStatus ${escapeHtml(step.status)}">${escapeHtml(statusLabel[step.status] || step.status)} · ${escapeHtml(formatDuration(step.durationMs))}</span>
|
|
1526
|
+
<time datetime="${escapeHtml(step.startedAt || "")}">${escapeHtml(formatTimelineTime(step.startedAt))}</time>
|
|
1527
|
+
</li>`;
|
|
1528
|
+
}).join("")}
|
|
1529
|
+
</ol>
|
|
1530
|
+
</section>`;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function renderAttempts(test) {
|
|
1534
|
+
if (!test.attempts?.length) return "";
|
|
1535
|
+
return `<section class="detailSection">
|
|
1536
|
+
<h3>Intentos y reintentos</h3>
|
|
1537
|
+
<div class="attemptList">
|
|
1538
|
+
${test.attempts.map((attempt) => `<details class="attempt" ${attempt.error ? "open" : ""}>
|
|
1539
|
+
<summary>Intento ${attempt.number} · ${escapeHtml(statusLabel[attempt.state] || attempt.state)} · ${escapeHtml(formatDuration(attempt.durationMs))}</summary>
|
|
1540
|
+
${attempt.error ? `<pre>${escapeHtml(attempt.error.stack || attempt.error.message)}</pre>` : "<p>Sin errores.</p>"}
|
|
1541
|
+
</details>`).join("")}
|
|
1542
|
+
</div>
|
|
1543
|
+
</section>`;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function firstErrorLine(error) {
|
|
1547
|
+
const value = String(error?.message || error?.stack || "").trim();
|
|
1548
|
+
return value.split(/\r?\n/).find((line) => line.trim())?.trim() || "Error sin detalle.";
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function formatHttpPayload(value) {
|
|
1552
|
+
if (typeof value === "string") return value;
|
|
1553
|
+
try {
|
|
1554
|
+
return JSON.stringify(value, null, 2);
|
|
1555
|
+
} catch {
|
|
1556
|
+
return String(value ?? "");
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function renderHttpExchanges(test) {
|
|
1561
|
+
const exchanges = Array.isArray(test.http) ? test.http : [];
|
|
1562
|
+
if (!exchanges.length) {
|
|
1563
|
+
return `<section class="detailSection">
|
|
1564
|
+
<h3>Intercambio HTTP</h3>
|
|
1565
|
+
<p class="httpEmpty">No se registraron requests para esta prueba fallida.</p>
|
|
1566
|
+
</section>`;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return `<section class="detailSection">
|
|
1570
|
+
<h3>Request y respuesta</h3>
|
|
1571
|
+
<div class="httpExchangeList">
|
|
1572
|
+
${exchanges.map((exchange, index) => {
|
|
1573
|
+
const request = exchange?.request || {};
|
|
1574
|
+
const response = exchange?.response || {};
|
|
1575
|
+
const requestLabel = `${request.method || "HTTP"} ${request.url || ""}`.trim();
|
|
1576
|
+
const responseLabel = response.status
|
|
1577
|
+
? `HTTP ${response.status}${response.statusText ? ` · ${response.statusText}` : ""}`
|
|
1578
|
+
: "Respuesta sin código HTTP";
|
|
1579
|
+
return `<article class="httpExchange">
|
|
1580
|
+
<p>Intercambio ${index + 1}</p>
|
|
1581
|
+
<details class="httpDisclosure">
|
|
1582
|
+
<summary>Request · ${escapeHtml(requestLabel)}</summary>
|
|
1583
|
+
<pre>${escapeHtml(formatHttpPayload(request))}</pre>
|
|
1584
|
+
</details>
|
|
1585
|
+
<details class="httpDisclosure">
|
|
1586
|
+
<summary>Respuesta · ${escapeHtml(responseLabel)}</summary>
|
|
1587
|
+
<pre>${escapeHtml(formatHttpPayload(response))}</pre>
|
|
1588
|
+
</details>
|
|
1589
|
+
</article>`;
|
|
1590
|
+
}).join("")}
|
|
1591
|
+
</div>
|
|
1592
|
+
</section>`;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function renderMedia(test) {
|
|
1596
|
+
const screenshots = (test.attempts || []).flatMap((attempt) =>
|
|
1597
|
+
(attempt.screenshots || []).filter((item) => item?.available));
|
|
1598
|
+
const videos = unique((test.attempts || [])
|
|
1599
|
+
.map((attempt) => attempt.video?.available ? attempt.video.reportPath : null));
|
|
1600
|
+
if (!screenshots.length && !videos.length) return "";
|
|
1601
|
+
return `<section class="detailSection">
|
|
1602
|
+
<h3>Evidencias</h3>
|
|
1603
|
+
<div class="mediaGrid">
|
|
1604
|
+
${screenshots.map((image) => `<a href="${escapeHtml(image.reportPath)}" target="_blank" rel="noreferrer">
|
|
1605
|
+
<img src="${escapeHtml(image.reportPath)}" alt="Captura de ${escapeHtml(test.title)}" loading="lazy" />
|
|
1606
|
+
</a>`).join("")}
|
|
1607
|
+
${videos.map((video) => `<video controls preload="metadata">
|
|
1608
|
+
<source src="${escapeHtml(video)}" />
|
|
1609
|
+
</video>`).join("")}
|
|
1610
|
+
</div>
|
|
1611
|
+
</section>`;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function renderAttachments(test) {
|
|
1615
|
+
if (!test.attachments?.length) return "";
|
|
1616
|
+
return `<section class="detailSection">
|
|
1617
|
+
<h3>Adjuntos</h3>
|
|
1618
|
+
<div class="attachmentList">
|
|
1619
|
+
${test.attachments.map((attachment) => `<div class="attachment">
|
|
1620
|
+
${attachment.available
|
|
1621
|
+
? `<a href="${escapeHtml(attachment.reportPath)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`
|
|
1622
|
+
: `<strong>${escapeHtml(attachment.name)}</strong>`}
|
|
1623
|
+
<span> · ${escapeHtml(attachment.mimeType)}</span>
|
|
1624
|
+
</div>`).join("")}
|
|
1625
|
+
</div>
|
|
1626
|
+
</section>`;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function renderFailureStatusControl(test) {
|
|
1630
|
+
const effectiveStatus = effectiveTestStatus(test);
|
|
1631
|
+
if (test.status !== "failed") return "";
|
|
1632
|
+
|
|
1633
|
+
const standardChoices = ["failed", ...Object.keys(customErrorStatuses)];
|
|
1634
|
+
const choices = standardChoices.includes(effectiveStatus)
|
|
1635
|
+
? standardChoices
|
|
1636
|
+
: [effectiveStatus, ...standardChoices];
|
|
1637
|
+
return `<label class="statusEditor">
|
|
1638
|
+
<span class="srOnly">Clasificación del resultado</span>
|
|
1639
|
+
<select
|
|
1640
|
+
class="statusPill statusSelect ${escapeHtml(effectiveStatus)}"
|
|
1641
|
+
data-failure-status="${escapeHtml(test.id)}"
|
|
1642
|
+
aria-label="Cambiar clasificación del resultado"
|
|
1643
|
+
>
|
|
1644
|
+
${choices.map((status) => `<option value="${escapeHtml(status)}" ${status === effectiveStatus ? "selected" : ""}>${escapeHtml(statusLabel[status])}</option>`).join("")}
|
|
1645
|
+
</select>
|
|
1646
|
+
</label>`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function renderFailureReview(test) {
|
|
1650
|
+
const effectiveStatus = effectiveTestStatus(test);
|
|
1651
|
+
const canReviewFailure =
|
|
1652
|
+
test.status === "failed" && editableFailureStatuses.has(effectiveStatus);
|
|
1653
|
+
if (!canReviewFailure) return "";
|
|
1654
|
+
|
|
1655
|
+
const annotation = annotationFor(test);
|
|
1656
|
+
const durablePersistence = location.protocol !== "file:";
|
|
1657
|
+
return `<section class="detailSection failureReview">
|
|
1658
|
+
<header class="failureReviewHeader">
|
|
1659
|
+
<div>
|
|
1660
|
+
<h3>Seguimiento de la falla</h3>
|
|
1661
|
+
<p>Explicá el motivo o vinculá el bug reportado.</p>
|
|
1662
|
+
</div>
|
|
1663
|
+
<span>${durablePersistence ? "Historial SQLite" : "Solo navegador"}</span>
|
|
1664
|
+
</header>
|
|
1665
|
+
<div class="failureReviewFields">
|
|
1666
|
+
<label>
|
|
1667
|
+
Comentario
|
|
1668
|
+
<textarea
|
|
1669
|
+
id="failure-comment-${escapeHtml(test.id)}"
|
|
1670
|
+
data-failure-comment
|
|
1671
|
+
rows="4"
|
|
1672
|
+
placeholder="Contexto del error, análisis o próximos pasos…"
|
|
1673
|
+
>${escapeHtml(annotation.comment || "")}</textarea>
|
|
1674
|
+
</label>
|
|
1675
|
+
<label>
|
|
1676
|
+
Ticket / bug report
|
|
1677
|
+
<input
|
|
1678
|
+
id="failure-ticket-${escapeHtml(test.id)}"
|
|
1679
|
+
data-failure-ticket
|
|
1680
|
+
type="text"
|
|
1681
|
+
value="${escapeHtml(annotation.ticket || "")}"
|
|
1682
|
+
placeholder="Ej.: BUG-1234 o URL del ticket"
|
|
1683
|
+
/>
|
|
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>
|
|
1690
|
+
${annotation.updatedAt
|
|
1691
|
+
? `<p class="auditSummary">Último cambio: ${escapeHtml(formatDate(annotation.updatedAt))} · ${escapeHtml(annotation.actor || "Usuario local")} · revisión ${escapeHtml(annotation.revision || 1)}</p>`
|
|
1692
|
+
: ""}
|
|
1693
|
+
<details class="auditTrail">
|
|
1694
|
+
<summary data-load-audit="${escapeHtml(test.id)}">Ver auditoría de cambios</summary>
|
|
1695
|
+
<div data-audit-events>Desplegá para consultar el historial inmutable.</div>
|
|
1696
|
+
</details>
|
|
1697
|
+
</section>`;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
async function loadAuditTrail(test, container) {
|
|
1701
|
+
container.innerHTML = "Consultando auditoría…";
|
|
1702
|
+
try {
|
|
1703
|
+
const result = await requestElmuloJson(
|
|
1704
|
+
`/api/audit?runId=${encodeURIComponent(run.id)}&testId=${encodeURIComponent(test.id)}`,
|
|
1705
|
+
);
|
|
1706
|
+
container.innerHTML = result.events.length
|
|
1707
|
+
? result.events.map((event) => `<article class="auditEvent">
|
|
1708
|
+
<strong>${escapeHtml(statusLabel[event.previous_status] || event.previous_status)} → ${escapeHtml(statusLabel[event.status] || event.status)}</strong>
|
|
1709
|
+
<span>${escapeHtml(formatDate(event.created_at))} · ${escapeHtml(event.actor)} · ${escapeHtml(event.source)}</span>
|
|
1710
|
+
${event.comment ? `<p>${escapeHtml(event.comment)}</p>` : ""}
|
|
1711
|
+
${event.ticket ? `<small>Ticket: ${escapeHtml(event.ticket)}</small>` : ""}
|
|
1712
|
+
</article>`).join("")
|
|
1713
|
+
: "Todavía no hay cambios manuales.";
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
container.textContent = error.message;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function renderTestHistoryModal() {
|
|
1720
|
+
const dialog = document.getElementById("test-history-modal");
|
|
1721
|
+
if (!dialog) return;
|
|
1722
|
+
|
|
1723
|
+
const entries = state.testHistoryEntries;
|
|
1724
|
+
const currentTest = run.tests.find(
|
|
1725
|
+
(test) => test.id === state.historyCurrentTestId,
|
|
1726
|
+
);
|
|
1727
|
+
const selectedEntry = selectedHistoryEntry();
|
|
1728
|
+
const selectedMatchesCurrent = Boolean(
|
|
1729
|
+
selectedEntry &&
|
|
1730
|
+
currentTest &&
|
|
1731
|
+
historicalStateMatchesCurrent(selectedEntry, currentTest),
|
|
1732
|
+
);
|
|
1733
|
+
const historyEnvironment = normalizeHistoryEnvironment(
|
|
1734
|
+
state.historyEnvironment ||
|
|
1735
|
+
entries?.[0]?.environment ||
|
|
1736
|
+
run.environment,
|
|
1737
|
+
);
|
|
1738
|
+
const visibleEntries = entries?.filter(
|
|
1739
|
+
(entry) =>
|
|
1740
|
+
normalizeHistoryEnvironment(entry.environment) === historyEnvironment,
|
|
1741
|
+
);
|
|
1742
|
+
dialog.innerHTML = `<div class="historyModalShell">
|
|
1743
|
+
<header class="historyModalHeader">
|
|
1744
|
+
<div>
|
|
1745
|
+
<p class="eyebrow">Historial por Jira</p>
|
|
1746
|
+
<h2>${escapeHtml(state.historyJiraId || "Prueba")}</h2>
|
|
1747
|
+
<p>${escapeHtml(state.historyTestTitle)}</p>
|
|
1748
|
+
</div>
|
|
1749
|
+
<button class="modalCloseButton" type="button" data-close-test-history aria-label="Cerrar historial">×</button>
|
|
1750
|
+
</header>
|
|
1751
|
+
<div class="historyModalBody">
|
|
1752
|
+
${entries?.length
|
|
1753
|
+
? `<div class="historyFilterBar">
|
|
1754
|
+
<label>
|
|
1755
|
+
<span>Ambiente</span>
|
|
1756
|
+
<select data-history-environment aria-label="Filtrar historial por ambiente">
|
|
1757
|
+
<option value="qa" ${historyEnvironment === "qa" ? "selected" : ""}>QA</option>
|
|
1758
|
+
<option value="sandbox" ${historyEnvironment === "sandbox" ? "selected" : ""}>Sandbox</option>
|
|
1759
|
+
</select>
|
|
1760
|
+
</label>
|
|
1761
|
+
<span>${escapeHtml(visibleEntries.length)} ejecución${visibleEntries.length === 1 ? "" : "es"}</span>
|
|
1762
|
+
</div>`
|
|
1763
|
+
: ""}
|
|
1764
|
+
${state.historyReuseMessage
|
|
1765
|
+
? `<div class="modalMessage success">${escapeHtml(state.historyReuseMessage)}</div>`
|
|
1766
|
+
: ""}
|
|
1767
|
+
${state.testHistoryError
|
|
1768
|
+
? `<div class="modalMessage error">${escapeHtml(state.testHistoryError)}</div>`
|
|
1769
|
+
: entries === null
|
|
1770
|
+
? `<div class="modalMessage">Cargando historial de estados…</div>`
|
|
1771
|
+
: entries.length === 0
|
|
1772
|
+
? `<div class="modalMessage">No hay ejecuciones registradas para este ID de Jira.</div>`
|
|
1773
|
+
: visibleEntries.length === 0
|
|
1774
|
+
? `<div class="modalMessage">No hay ejecuciones registradas para ${escapeHtml(historyEnvironment.toUpperCase())}.</div>`
|
|
1775
|
+
: visibleEntries.map((entry, index) => {
|
|
1776
|
+
const entryKey = `${entry.run_id}\u001f${entry.test_id}`;
|
|
1777
|
+
const notReusable = Boolean(
|
|
1778
|
+
currentTest && historicalStateMatchesCurrent(entry, currentTest),
|
|
1779
|
+
);
|
|
1780
|
+
const selected =
|
|
1781
|
+
!notReusable && entryKey === state.selectedHistoryKey;
|
|
1782
|
+
const isLatest = index === 0;
|
|
1783
|
+
const isCurrent =
|
|
1784
|
+
entry.run_id === run.id &&
|
|
1785
|
+
entry.test_id === state.historyCurrentTestId;
|
|
1786
|
+
return `<article
|
|
1787
|
+
class="historyEntry ${selected ? "selected" : ""} ${isLatest ? "latest" : ""} ${isCurrent ? "currentExecution" : ""} ${notReusable ? "notReusable" : ""}"
|
|
1788
|
+
data-history-run="${escapeHtml(entry.run_id)}"
|
|
1789
|
+
data-history-test="${escapeHtml(entry.test_id)}"
|
|
1790
|
+
aria-disabled="${notReusable}"
|
|
1791
|
+
>
|
|
1792
|
+
<header>
|
|
1793
|
+
<div class="historyEntryHeading">
|
|
1794
|
+
<label class="historyRadio ${notReusable ? "disabled" : ""}" ${notReusable ? 'title="Esta ejecución ya coincide con el estado actual"' : ""}>
|
|
1795
|
+
<input
|
|
1796
|
+
type="radio"
|
|
1797
|
+
name="jira-history-entry"
|
|
1798
|
+
value="${escapeHtml(entryKey)}"
|
|
1799
|
+
${selected ? "checked" : ""}
|
|
1800
|
+
${notReusable ? "disabled" : ""}
|
|
1801
|
+
/>
|
|
1802
|
+
<span class="srOnly">${notReusable ? "No se puede reutilizar porque ya coincide con la ejecución actual" : "Seleccionar"} ejecución del ${escapeHtml(formatDate(entry.started_at))}</span>
|
|
1803
|
+
</label>
|
|
1804
|
+
<div>
|
|
1805
|
+
<div class="historyDateLine">
|
|
1806
|
+
<strong>${escapeHtml(formatDate(entry.started_at))}</strong>
|
|
1807
|
+
${isCurrent
|
|
1808
|
+
? '<span class="currentExecutionBadge">Ejecución actual</span>'
|
|
1809
|
+
: isLatest
|
|
1810
|
+
? '<span class="latestExecutionBadge">Última ejecución</span>'
|
|
1811
|
+
: ""}
|
|
1812
|
+
</div>
|
|
1813
|
+
<span>${escapeHtml(entry.title)}</span>
|
|
1814
|
+
</div>
|
|
1815
|
+
</div>
|
|
1816
|
+
<div class="historyExecutionState">
|
|
1817
|
+
<span class="historyEnvironment">${escapeHtml(String(entry.environment || "").toUpperCase())}</span>
|
|
1818
|
+
<span class="statusPill ${escapeHtml(entry.status)}">${escapeHtml(statusLabel[entry.status] || entry.status)}</span>
|
|
1819
|
+
</div>
|
|
1820
|
+
</header>
|
|
1821
|
+
<details class="historyEntryDetails">
|
|
1822
|
+
<summary>Comentario y ticket / bug reportado</summary>
|
|
1823
|
+
<dl>
|
|
1824
|
+
<div>
|
|
1825
|
+
<dt>Comentario</dt>
|
|
1826
|
+
<dd>${entry.comment ? escapeHtml(entry.comment) : "Sin comentario."}</dd>
|
|
1827
|
+
</div>
|
|
1828
|
+
<div>
|
|
1829
|
+
<dt>Ticket / bug reportado</dt>
|
|
1830
|
+
<dd>${entry.ticket ? escapeHtml(entry.ticket) : "Sin ticket asociado."}</dd>
|
|
1831
|
+
</div>
|
|
1832
|
+
</dl>
|
|
1833
|
+
</details>
|
|
1834
|
+
<footer>
|
|
1835
|
+
<span>${escapeHtml(formatDuration(entry.duration_ms))} · ${escapeHtml(entry.spec)}</span>
|
|
1836
|
+
</footer>
|
|
1837
|
+
<div class="historyEntryFeedback" data-history-feedback aria-live="polite"></div>
|
|
1838
|
+
</article>`;
|
|
1839
|
+
}).join("")}
|
|
1840
|
+
</div>
|
|
1841
|
+
${visibleEntries?.length
|
|
1842
|
+
? `<footer class="historyModalFooter">
|
|
1843
|
+
<span data-history-selection-label>${state.selectedHistoryKey
|
|
1844
|
+
? selectedMatchesCurrent
|
|
1845
|
+
? "El estado, comentario y ticket ya coinciden con la ejecución actual"
|
|
1846
|
+
: "1 ejecución seleccionada"
|
|
1847
|
+
: "Seleccioná una ejecución para reutilizar su estado"}</span>
|
|
1848
|
+
<button
|
|
1849
|
+
class="reuseStateButton"
|
|
1850
|
+
type="button"
|
|
1851
|
+
data-open-reuse-confirmation
|
|
1852
|
+
${state.selectedHistoryKey && !selectedMatchesCurrent ? "" : "disabled"}
|
|
1853
|
+
>Reutilizar estado</button>
|
|
1854
|
+
</footer>`
|
|
1855
|
+
: ""}
|
|
1856
|
+
</div>`;
|
|
1857
|
+
|
|
1858
|
+
dialog.querySelector("[data-close-test-history]")?.addEventListener("click", () => {
|
|
1859
|
+
dialog.close();
|
|
1860
|
+
});
|
|
1861
|
+
dialog.querySelector("[data-history-environment]")?.addEventListener(
|
|
1862
|
+
"change",
|
|
1863
|
+
(event) => {
|
|
1864
|
+
state.historyEnvironment = normalizeHistoryEnvironment(event.target.value);
|
|
1865
|
+
state.selectedHistoryKey = null;
|
|
1866
|
+
renderTestHistoryModal();
|
|
1867
|
+
},
|
|
1868
|
+
);
|
|
1869
|
+
dialog.querySelectorAll('input[name="jira-history-entry"]').forEach((radio) => {
|
|
1870
|
+
radio.addEventListener("change", () => {
|
|
1871
|
+
state.selectedHistoryKey = radio.value;
|
|
1872
|
+
dialog.querySelectorAll(".historyEntry").forEach((entryElement) => {
|
|
1873
|
+
const key =
|
|
1874
|
+
`${entryElement.dataset.historyRun}\u001f${entryElement.dataset.historyTest}`;
|
|
1875
|
+
entryElement.classList.toggle("selected", key === state.selectedHistoryKey);
|
|
1876
|
+
});
|
|
1877
|
+
const actionButton = dialog.querySelector("[data-open-reuse-confirmation]");
|
|
1878
|
+
const selectionLabel = dialog.querySelector("[data-history-selection-label]");
|
|
1879
|
+
const entry = selectedHistoryEntry();
|
|
1880
|
+
const test = run.tests.find(
|
|
1881
|
+
(candidate) => candidate.id === state.historyCurrentTestId,
|
|
1882
|
+
);
|
|
1883
|
+
const unchanged = Boolean(
|
|
1884
|
+
entry && test && historicalStateMatchesCurrent(entry, test),
|
|
1885
|
+
);
|
|
1886
|
+
if (actionButton) actionButton.disabled = !entry || !test || unchanged;
|
|
1887
|
+
if (selectionLabel) {
|
|
1888
|
+
selectionLabel.textContent = unchanged
|
|
1889
|
+
? "El estado, comentario y ticket ya coinciden con la ejecución actual"
|
|
1890
|
+
: "1 ejecución seleccionada";
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
dialog.querySelector("[data-open-reuse-confirmation]")?.addEventListener(
|
|
1895
|
+
"click",
|
|
1896
|
+
openReuseConfirmation,
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function selectedHistoryEntry() {
|
|
1901
|
+
return (state.testHistoryEntries || []).find(
|
|
1902
|
+
(entry) => `${entry.run_id}\u001f${entry.test_id}` === state.selectedHistoryKey,
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function normalizeHistoryEnvironment(value) {
|
|
1907
|
+
return String(value || "").toLowerCase() === "qa" ? "qa" : "sandbox";
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function normalizedHistoricalValue(value) {
|
|
1911
|
+
return String(value || "").trim();
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function historicalStateMatchesCurrent(entry, currentTest) {
|
|
1915
|
+
const annotation = annotationFor(currentTest);
|
|
1916
|
+
return entry.status === effectiveTestStatus(currentTest) &&
|
|
1917
|
+
normalizedHistoricalValue(entry.comment) ===
|
|
1918
|
+
normalizedHistoricalValue(annotation.comment) &&
|
|
1919
|
+
normalizedHistoricalValue(entry.ticket) ===
|
|
1920
|
+
normalizedHistoricalValue(annotation.ticket);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function openReuseConfirmation() {
|
|
1924
|
+
const entry = selectedHistoryEntry();
|
|
1925
|
+
const currentTest = run.tests.find(
|
|
1926
|
+
(test) => test.id === state.historyCurrentTestId,
|
|
1927
|
+
);
|
|
1928
|
+
if (!entry || !currentTest) return;
|
|
1929
|
+
if (historicalStateMatchesCurrent(entry, currentTest)) {
|
|
1930
|
+
renderTestHistoryModal();
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const currentStatus = effectiveTestStatus(currentTest);
|
|
1935
|
+
const confirmationDialog = document.getElementById("reuse-confirmation-modal");
|
|
1936
|
+
confirmationDialog.innerHTML = `<div class="confirmationShell">
|
|
1937
|
+
<header>
|
|
1938
|
+
<div>
|
|
1939
|
+
<p class="eyebrow">Confirmar reutilización</p>
|
|
1940
|
+
<h2>¿Aplicar este estado histórico?</h2>
|
|
1941
|
+
</div>
|
|
1942
|
+
<button class="modalCloseButton" type="button" data-cancel-reuse aria-label="Cancelar">×</button>
|
|
1943
|
+
</header>
|
|
1944
|
+
<div class="confirmationBody">
|
|
1945
|
+
<p>Se reemplazarán el estado, comentario y ticket de la ejecución actual.</p>
|
|
1946
|
+
<div class="statusComparison">
|
|
1947
|
+
<article>
|
|
1948
|
+
<span>Estado actual</span>
|
|
1949
|
+
<strong class="statusPill ${escapeHtml(currentStatus)}">${escapeHtml(statusLabel[currentStatus] || currentStatus)}</strong>
|
|
1950
|
+
</article>
|
|
1951
|
+
<span class="comparisonArrow" aria-hidden="true">→</span>
|
|
1952
|
+
<article>
|
|
1953
|
+
<span>Estado histórico</span>
|
|
1954
|
+
<strong class="statusPill ${escapeHtml(entry.status)}">${escapeHtml(statusLabel[entry.status] || entry.status)}</strong>
|
|
1955
|
+
<small>${escapeHtml(formatDate(entry.started_at))} · ${escapeHtml(String(entry.environment || "").toUpperCase())}</small>
|
|
1956
|
+
</article>
|
|
1957
|
+
</div>
|
|
1958
|
+
<div class="confirmationMetadata">
|
|
1959
|
+
<div><strong>Comentario</strong><span>${entry.comment ? escapeHtml(entry.comment) : "Sin comentario."}</span></div>
|
|
1960
|
+
<div><strong>Ticket / bug reportado</strong><span>${entry.ticket ? escapeHtml(entry.ticket) : "Sin ticket asociado."}</span></div>
|
|
1961
|
+
</div>
|
|
1962
|
+
<div class="confirmationFeedback" data-confirmation-feedback aria-live="polite"></div>
|
|
1963
|
+
</div>
|
|
1964
|
+
<footer>
|
|
1965
|
+
<button class="cancelReuseButton" type="button" data-cancel-reuse>Cancelar</button>
|
|
1966
|
+
<button class="confirmReuseButton" type="button" data-confirm-reuse>Confirmar cambio</button>
|
|
1967
|
+
</footer>
|
|
1968
|
+
</div>`;
|
|
1969
|
+
|
|
1970
|
+
const historyDialog = document.getElementById("test-history-modal");
|
|
1971
|
+
historyDialog.close();
|
|
1972
|
+
confirmationDialog.showModal();
|
|
1973
|
+
confirmationDialog.querySelectorAll("[data-cancel-reuse]").forEach((button) => {
|
|
1974
|
+
button.addEventListener("click", () => {
|
|
1975
|
+
confirmationDialog.close();
|
|
1976
|
+
historyDialog.showModal();
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
confirmationDialog.querySelector("[data-confirm-reuse]")?.addEventListener(
|
|
1980
|
+
"click",
|
|
1981
|
+
() => applySelectedHistoricalState(entry, currentTest),
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function applySelectedHistoricalState(entry, currentTest) {
|
|
1986
|
+
if (historicalStateMatchesCurrent(entry, currentTest)) {
|
|
1987
|
+
renderTestHistoryModal();
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const confirmationDialog = document.getElementById("reuse-confirmation-modal");
|
|
1991
|
+
const confirmButton = confirmationDialog.querySelector("[data-confirm-reuse]");
|
|
1992
|
+
const feedback = confirmationDialog.querySelector("[data-confirmation-feedback]");
|
|
1993
|
+
confirmButton.disabled = true;
|
|
1994
|
+
if (feedback) feedback.textContent = "Aplicando estado histórico…";
|
|
1995
|
+
|
|
1996
|
+
const previousAnnotation = state.annotations[currentTest.id]
|
|
1997
|
+
? { ...state.annotations[currentTest.id] }
|
|
1998
|
+
: null;
|
|
1999
|
+
state.annotations[currentTest.id] = {
|
|
2000
|
+
...annotationFor(currentTest),
|
|
2001
|
+
status: entry.status,
|
|
2002
|
+
comment: entry.comment || "",
|
|
2003
|
+
ticket: entry.ticket || "",
|
|
2004
|
+
updatedAt: new Date().toISOString(),
|
|
2005
|
+
};
|
|
2006
|
+
const result = await persistAnnotation(currentTest.id);
|
|
2007
|
+
if (!result.durable) {
|
|
2008
|
+
if (previousAnnotation) {
|
|
2009
|
+
state.annotations[currentTest.id] = previousAnnotation;
|
|
2010
|
+
} else {
|
|
2011
|
+
delete state.annotations[currentTest.id];
|
|
2012
|
+
}
|
|
2013
|
+
persistAnnotationsLocally();
|
|
2014
|
+
if (feedback) {
|
|
2015
|
+
feedback.textContent = result.error || "No se pudo guardar el cambio en SQLite.";
|
|
2016
|
+
feedback.classList.add("error");
|
|
2017
|
+
}
|
|
2018
|
+
confirmButton.disabled = false;
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
state.historyReuseMessage =
|
|
2023
|
+
`Se aplicó el estado ${statusLabel[entry.status] || entry.status} a la ejecución actual.`;
|
|
2024
|
+
state.selectedHistoryKey = null;
|
|
2025
|
+
const historyResult = await requestElmuloJson(
|
|
2026
|
+
`/api/test-history?jiraId=${encodeURIComponent(state.historyJiraId)}`,
|
|
2027
|
+
);
|
|
2028
|
+
state.testHistoryEntries = historyResult.history || [];
|
|
2029
|
+
renderList();
|
|
2030
|
+
renderTestHistoryModal();
|
|
2031
|
+
confirmationDialog.close();
|
|
2032
|
+
document.getElementById("test-history-modal").showModal();
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async function openTestHistory(test) {
|
|
2036
|
+
const jiraId = jiraIdForTest(test);
|
|
2037
|
+
if (!jiraId) return;
|
|
2038
|
+
state.historyJiraId = jiraId;
|
|
2039
|
+
state.historyTestTitle = originalTestTitle(test);
|
|
2040
|
+
state.historyCurrentTestId = test.id;
|
|
2041
|
+
state.historyEnvironment = null;
|
|
2042
|
+
state.selectedHistoryKey = null;
|
|
2043
|
+
state.testHistoryEntries = null;
|
|
2044
|
+
state.testHistoryError = "";
|
|
2045
|
+
state.historyReuseMessage = "";
|
|
2046
|
+
|
|
2047
|
+
const dialog = document.getElementById("test-history-modal");
|
|
2048
|
+
renderTestHistoryModal();
|
|
2049
|
+
if (!dialog.open) dialog.showModal();
|
|
2050
|
+
|
|
2051
|
+
try {
|
|
2052
|
+
const result = await requestElmuloJson(
|
|
2053
|
+
`/api/test-history?jiraId=${encodeURIComponent(jiraId)}`,
|
|
2054
|
+
);
|
|
2055
|
+
state.testHistoryEntries = result.history || [];
|
|
2056
|
+
state.historyEnvironment = normalizeHistoryEnvironment(
|
|
2057
|
+
state.testHistoryEntries[0]?.environment || run.environment,
|
|
2058
|
+
);
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
state.testHistoryEntries = [];
|
|
2061
|
+
state.testHistoryError = error.message;
|
|
2062
|
+
}
|
|
2063
|
+
renderTestHistoryModal();
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function renderDetail(test) {
|
|
2067
|
+
const detail = document.getElementById("test-detail");
|
|
2068
|
+
if (!test) {
|
|
2069
|
+
detail.innerHTML = `<div class="emptyState"><strong>Sin selección</strong><span>Elegí una prueba para ver sus detalles.</span></div>`;
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
detail.innerHTML = `
|
|
2073
|
+
<header class="detailHeader">
|
|
2074
|
+
<div>
|
|
2075
|
+
<p class="eyebrow">${escapeHtml(test.feature || test.suite || "Prueba")}</p>
|
|
2076
|
+
<h2>${escapeHtml(test.title)}</h2>
|
|
2077
|
+
<p class="testSpec">${escapeHtml(test.spec)}</p>
|
|
2078
|
+
</div>
|
|
2079
|
+
<div class="detailStatusColumn">
|
|
2080
|
+
<span class="statusPairLabel">Resultado automático</span>
|
|
2081
|
+
<span class="statusPill ${escapeHtml(test.status)}">${escapeHtml(statusLabel[test.status] || test.status)}</span>
|
|
2082
|
+
${test.status === "failed"
|
|
2083
|
+
? `<span class="statusPairLabel">Clasificación manual</span>${renderFailureStatusControl(test)}`
|
|
2084
|
+
: ""}
|
|
2085
|
+
${jiraIdForTest(test)
|
|
2086
|
+
? `<button class="jiraHistoryButton" type="button" data-test-history="${escapeHtml(test.id)}">Ver historial · ${escapeHtml(jiraIdForTest(test))}</button>`
|
|
2087
|
+
: ""}
|
|
2088
|
+
</div>
|
|
2089
|
+
</header>
|
|
2090
|
+
<div>
|
|
2091
|
+
${(test.tags || []).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
|
|
2092
|
+
${test.flaky ? '<span class="tag">⚠ Inestable</span>' : ""}
|
|
2093
|
+
</div>
|
|
2094
|
+
<nav class="detailTabs" role="tablist" aria-label="Detalle de la prueba">
|
|
2095
|
+
${[
|
|
2096
|
+
["steps", "Pasos"],
|
|
2097
|
+
["error", "Error"],
|
|
2098
|
+
["attempts", "Intentos"],
|
|
2099
|
+
["evidence", "Evidencias"],
|
|
2100
|
+
["history", "Historial"],
|
|
2101
|
+
].map(([key, label]) => `<button
|
|
2102
|
+
type="button"
|
|
2103
|
+
role="tab"
|
|
2104
|
+
aria-selected="${state.detailTab === key}"
|
|
2105
|
+
data-detail-tab="${key}"
|
|
2106
|
+
>${label}</button>`).join("")}
|
|
2107
|
+
</nav>
|
|
2108
|
+
<div class="detailTabPanel" role="tabpanel">
|
|
2109
|
+
${state.detailTab === "steps"
|
|
2110
|
+
? renderCucumberTimeline(test) ||
|
|
2111
|
+
'<div class="emptyState compact"><strong>Sin pasos Cucumber registrados.</strong></div>'
|
|
2112
|
+
: ""}
|
|
2113
|
+
${state.detailTab === "error"
|
|
2114
|
+
? `${test.error
|
|
2115
|
+
? `<section class="detailSection"><h3>Error final</h3><pre>${escapeHtml(firstErrorLine(test.error))}</pre></section>`
|
|
2116
|
+
: '<div class="emptyState compact"><strong>Esta ejecución no tiene un error final.</strong></div>'}
|
|
2117
|
+
${test.status === "failed" ? renderHttpExchanges(test) : ""}
|
|
2118
|
+
${renderFailureReview(test)}`
|
|
2119
|
+
: ""}
|
|
2120
|
+
${state.detailTab === "attempts" ? renderAttempts(test) : ""}
|
|
2121
|
+
${state.detailTab === "evidence"
|
|
2122
|
+
? `${renderMedia(test)}${renderAttachments(test)}` ||
|
|
2123
|
+
'<div class="emptyState compact"><strong>Sin evidencias disponibles.</strong></div>'
|
|
2124
|
+
: ""}
|
|
2125
|
+
${state.detailTab === "history"
|
|
2126
|
+
? `<section class="detailSection">
|
|
2127
|
+
<h3>Historial y auditoría</h3>
|
|
2128
|
+
${jiraIdForTest(test)
|
|
2129
|
+
? `<button class="jiraHistoryButton" type="button" data-test-history="${escapeHtml(test.id)}">Abrir historial · ${escapeHtml(jiraIdForTest(test))}</button>`
|
|
2130
|
+
: "<p>La prueba no tiene un tag Jira asociado.</p>"}
|
|
2131
|
+
<details class="auditTrail">
|
|
2132
|
+
<summary data-load-audit="${escapeHtml(test.id)}">Auditoría de cambios manuales</summary>
|
|
2133
|
+
<div data-audit-events>Desplegá para consultar el historial inmutable.</div>
|
|
2134
|
+
</details>
|
|
2135
|
+
</section>`
|
|
2136
|
+
: ""}
|
|
2137
|
+
</div>
|
|
2138
|
+
`;
|
|
2139
|
+
|
|
2140
|
+
const debugButton = detail.querySelector("[data-debug-test]");
|
|
2141
|
+
if (debugButton) {
|
|
2142
|
+
debugButton.addEventListener("click", () => {
|
|
2143
|
+
if (state.debugTestIds.has(test.id)) {
|
|
2144
|
+
state.debugTestIds.delete(test.id);
|
|
2145
|
+
} else {
|
|
2146
|
+
state.debugTestIds.add(test.id);
|
|
2147
|
+
}
|
|
2148
|
+
renderDetail(test);
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
detail.querySelectorAll("[data-test-history]").forEach((button) => {
|
|
2153
|
+
button.addEventListener("click", () => {
|
|
2154
|
+
openTestHistory(test);
|
|
2155
|
+
});
|
|
2156
|
+
});
|
|
2157
|
+
detail.querySelectorAll("[data-detail-tab]").forEach((button) => {
|
|
2158
|
+
button.addEventListener("click", () => {
|
|
2159
|
+
state.detailTab = button.dataset.detailTab;
|
|
2160
|
+
renderDetail(test);
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
const statusSelect = detail.querySelector("[data-failure-status]");
|
|
2165
|
+
if (statusSelect) {
|
|
2166
|
+
statusSelect.addEventListener("change", async (event) => {
|
|
2167
|
+
const status = event.target.value;
|
|
2168
|
+
if (!editableFailureStatuses.has(status)) return;
|
|
2169
|
+
statusSelect.disabled = true;
|
|
2170
|
+
state.annotations[test.id] = {
|
|
2171
|
+
...annotationFor(test),
|
|
2172
|
+
status,
|
|
2173
|
+
updatedAt: new Date().toISOString(),
|
|
2174
|
+
};
|
|
2175
|
+
const result = await persistAnnotation(test.id);
|
|
2176
|
+
renderList();
|
|
2177
|
+
const feedback = document.querySelector("[data-save-feedback]");
|
|
2178
|
+
if (feedback && (!result.durable || result.error)) {
|
|
2179
|
+
feedback.textContent = result.error
|
|
2180
|
+
? `No se pudo guardar en SQLite: ${result.error}`
|
|
2181
|
+
: "Guardado solo en este navegador. Abrí el reporte con Elmulo Serve para persistirlo.";
|
|
2182
|
+
feedback.classList.add("error");
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const saveReviewButton = detail.querySelector("[data-save-failure-review]");
|
|
2188
|
+
if (saveReviewButton) {
|
|
2189
|
+
saveReviewButton.addEventListener("click", async () => {
|
|
2190
|
+
const comment = detail.querySelector("[data-failure-comment]")?.value.trim() || "";
|
|
2191
|
+
const ticket = detail.querySelector("[data-failure-ticket]")?.value.trim() || "";
|
|
2192
|
+
saveReviewButton.disabled = true;
|
|
2193
|
+
state.annotations[test.id] = {
|
|
2194
|
+
...annotationFor(test),
|
|
2195
|
+
status: effectiveTestStatus(test),
|
|
2196
|
+
comment,
|
|
2197
|
+
ticket,
|
|
2198
|
+
updatedAt: new Date().toISOString(),
|
|
2199
|
+
};
|
|
2200
|
+
const result = await persistAnnotation(test.id);
|
|
2201
|
+
const feedback = detail.querySelector("[data-save-feedback]");
|
|
2202
|
+
if (feedback) {
|
|
2203
|
+
feedback.textContent = result.durable
|
|
2204
|
+
? "Seguimiento guardado en SQLite."
|
|
2205
|
+
: result.error
|
|
2206
|
+
? `No se pudo guardar en SQLite: ${result.error}`
|
|
2207
|
+
: "Guardado solo en este navegador. Abrí el reporte con Elmulo Serve para persistirlo.";
|
|
2208
|
+
feedback.classList.toggle("error", !result.durable);
|
|
2209
|
+
}
|
|
2210
|
+
saveReviewButton.disabled = false;
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
const auditDetails = detail.querySelector(".auditTrail");
|
|
2214
|
+
auditDetails?.addEventListener("toggle", () => {
|
|
2215
|
+
if (auditDetails.open && !auditDetails.dataset.loaded) {
|
|
2216
|
+
auditDetails.dataset.loaded = "true";
|
|
2217
|
+
loadAuditTrail(test, auditDetails.querySelector("[data-audit-events]"));
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function renderList() {
|
|
2223
|
+
renderSummary();
|
|
2224
|
+
const tests = filteredTests();
|
|
2225
|
+
const visibleGroups = groupTestsByCase(tests);
|
|
2226
|
+
const allGroups = groupTestsByCase(run.tests);
|
|
2227
|
+
const rows = document.getElementById("test-rows");
|
|
2228
|
+
const count = document.getElementById("visible-count");
|
|
2229
|
+
const selectedCount = document.getElementById("selected-count");
|
|
2230
|
+
const bulkButton = document.getElementById("apply-bulk");
|
|
2231
|
+
count.textContent =
|
|
2232
|
+
`${visibleGroups.length} de ${allGroups.length} casos de prueba · ` +
|
|
2233
|
+
`${tests.length} de ${run.tests.length} ejecuciones`;
|
|
2234
|
+
if (selectedCount) {
|
|
2235
|
+
selectedCount.textContent = `${state.bulkSelected.size} seleccionada${state.bulkSelected.size === 1 ? "" : "s"}`;
|
|
2236
|
+
}
|
|
2237
|
+
if (bulkButton) bulkButton.disabled = state.bulkSelected.size === 0;
|
|
2238
|
+
rows.innerHTML = visibleGroups.length
|
|
2239
|
+
? visibleGroups.map(renderTestCaseGroup).join("")
|
|
2240
|
+
: `<div class="emptyState"><strong>No encontramos resultados</strong><span>Probá modificando los filtros.</span></div>`;
|
|
2241
|
+
|
|
2242
|
+
if (state.selectedId && !tests.some((test) => test.id === state.selectedId)) {
|
|
2243
|
+
state.selectedId = null;
|
|
2244
|
+
syncUrlState();
|
|
2245
|
+
}
|
|
2246
|
+
renderDetail(run.tests.find((test) => test.id === state.selectedId));
|
|
2247
|
+
|
|
2248
|
+
rows.querySelectorAll("[data-test-id]").forEach((button) => {
|
|
2249
|
+
button.addEventListener("click", () => {
|
|
2250
|
+
state.selectedId = button.dataset.testId;
|
|
2251
|
+
syncUrlState();
|
|
2252
|
+
renderList();
|
|
2253
|
+
});
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
rows.querySelectorAll("[data-example-group]").forEach((button) => {
|
|
2257
|
+
button.addEventListener("click", () => {
|
|
2258
|
+
const groupId = button.dataset.exampleGroup;
|
|
2259
|
+
if (state.expandedExampleGroups.has(groupId)) {
|
|
2260
|
+
state.expandedExampleGroups.delete(groupId);
|
|
2261
|
+
} else {
|
|
2262
|
+
state.expandedExampleGroups.add(groupId);
|
|
2263
|
+
}
|
|
2264
|
+
renderList();
|
|
2265
|
+
});
|
|
2266
|
+
});
|
|
2267
|
+
rows.querySelectorAll("[data-select-test]").forEach((checkbox) => {
|
|
2268
|
+
checkbox.addEventListener("change", () => {
|
|
2269
|
+
if (checkbox.checked) state.bulkSelected.add(checkbox.dataset.selectTest);
|
|
2270
|
+
else state.bulkSelected.delete(checkbox.dataset.selectTest);
|
|
2271
|
+
renderList();
|
|
2272
|
+
});
|
|
2273
|
+
});
|
|
2274
|
+
const selectVisible = document.getElementById("select-visible");
|
|
2275
|
+
if (selectVisible) {
|
|
2276
|
+
selectVisible.checked = tests.length > 0 &&
|
|
2277
|
+
tests.every((test) => state.bulkSelected.has(test.id));
|
|
2278
|
+
selectVisible.indeterminate = tests.some((test) =>
|
|
2279
|
+
state.bulkSelected.has(test.id)) && !selectVisible.checked;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function activeFilterChips() {
|
|
2284
|
+
const labels = {
|
|
2285
|
+
status: statusLabel[state.status] || (state.status === "other_errors" ? "Otros errores" : state.status),
|
|
2286
|
+
spec: state.spec.split("/").at(-1),
|
|
2287
|
+
tag: state.tag,
|
|
2288
|
+
flaky: state.flaky === "yes" ? "Sólo inestables" : "Excluir inestables",
|
|
2289
|
+
search: state.search,
|
|
2290
|
+
};
|
|
2291
|
+
const active = [
|
|
2292
|
+
state.search ? ["search", `Búsqueda: ${labels.search}`] : null,
|
|
2293
|
+
state.status !== "all" ? ["status", labels.status] : null,
|
|
2294
|
+
state.spec !== "all" ? ["spec", labels.spec] : null,
|
|
2295
|
+
state.tag !== "all" ? ["tag", labels.tag] : null,
|
|
2296
|
+
state.flaky !== "all" ? ["flaky", labels.flaky] : null,
|
|
2297
|
+
].filter(Boolean);
|
|
2298
|
+
if (!active.length) return '<span class="noFilters">Sin filtros activos</span>';
|
|
2299
|
+
return `${active.map(([key, label]) => `<button type="button" data-remove-filter="${key}">${escapeHtml(label)} <span aria-hidden="true">×</span></button>`).join("")}
|
|
2300
|
+
<button class="clearFilters" type="button" data-clear-filters>Limpiar filtros</button>`;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function bindFilterChipEvents() {
|
|
2304
|
+
document.querySelectorAll("[data-remove-filter]").forEach((button) => {
|
|
2305
|
+
button.addEventListener("click", () => {
|
|
2306
|
+
const key = button.dataset.removeFilter;
|
|
2307
|
+
if (key === "search") state.search = "";
|
|
2308
|
+
else state[key] = "all";
|
|
2309
|
+
syncUrlState();
|
|
2310
|
+
render();
|
|
2311
|
+
});
|
|
2312
|
+
});
|
|
2313
|
+
document.querySelector("[data-clear-filters]")?.addEventListener("click", clearFilters);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function clearFilters() {
|
|
2317
|
+
Object.assign(state, {
|
|
2318
|
+
search: "",
|
|
2319
|
+
status: "all",
|
|
2320
|
+
spec: "all",
|
|
2321
|
+
tag: "all",
|
|
2322
|
+
flaky: "all",
|
|
2323
|
+
attentionFilter: "",
|
|
2324
|
+
});
|
|
2325
|
+
syncUrlState();
|
|
2326
|
+
render();
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function filterDropdownMarkup(id, label, options, selectedValue, help = "") {
|
|
2330
|
+
const selected = options.find(([value]) => value === selectedValue) || options[0];
|
|
2331
|
+
return `<div class="field">
|
|
2332
|
+
<span id="${id}-label" class="filterFieldLabel">${escapeHtml(label)}${help}</span>
|
|
2333
|
+
<div class="filterDropdown" data-filter-dropdown="${id}">
|
|
2334
|
+
<button
|
|
2335
|
+
id="${id}"
|
|
2336
|
+
class="filterDropdownTrigger"
|
|
2337
|
+
type="button"
|
|
2338
|
+
aria-haspopup="listbox"
|
|
2339
|
+
aria-expanded="false"
|
|
2340
|
+
aria-labelledby="${id}-label ${id}-value"
|
|
2341
|
+
>
|
|
2342
|
+
<span id="${id}-value">${escapeHtml(selected?.[1] || "")}</span>
|
|
2343
|
+
<span class="filterDropdownChevron" aria-hidden="true">⌄</span>
|
|
2344
|
+
</button>
|
|
2345
|
+
<div
|
|
2346
|
+
class="filterDropdownMenu"
|
|
2347
|
+
role="listbox"
|
|
2348
|
+
aria-labelledby="${id}-label"
|
|
2349
|
+
data-visible-options="10"
|
|
2350
|
+
tabindex="-1"
|
|
2351
|
+
hidden
|
|
2352
|
+
>
|
|
2353
|
+
${options.map(([value, optionLabel]) => `<button
|
|
2354
|
+
type="button"
|
|
2355
|
+
role="option"
|
|
2356
|
+
data-filter-option="${escapeHtml(value)}"
|
|
2357
|
+
aria-selected="${value === selectedValue}"
|
|
2358
|
+
title="${escapeHtml(optionLabel)}"
|
|
2359
|
+
>${escapeHtml(optionLabel)}</button>`).join("")}
|
|
2360
|
+
</div>
|
|
2361
|
+
</div>
|
|
2362
|
+
</div>`;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function closeFilterDropdowns(except = null) {
|
|
2366
|
+
document.querySelectorAll("[data-filter-dropdown]").forEach((dropdown) => {
|
|
2367
|
+
if (dropdown === except) return;
|
|
2368
|
+
dropdown.querySelector(".filterDropdownMenu")?.setAttribute("hidden", "");
|
|
2369
|
+
dropdown.querySelector(".filterDropdownTrigger")
|
|
2370
|
+
?.setAttribute("aria-expanded", "false");
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function bindTestFilterDropdowns() {
|
|
2375
|
+
document.querySelectorAll("[data-filter-dropdown]").forEach((dropdown) => {
|
|
2376
|
+
const key = dropdown.dataset.filterDropdown;
|
|
2377
|
+
const trigger = dropdown.querySelector(".filterDropdownTrigger");
|
|
2378
|
+
const menu = dropdown.querySelector(".filterDropdownMenu");
|
|
2379
|
+
const options = [...dropdown.querySelectorAll("[data-filter-option]")];
|
|
2380
|
+
const open = (focusOption = false) => {
|
|
2381
|
+
closeFilterDropdowns(dropdown);
|
|
2382
|
+
menu.removeAttribute("hidden");
|
|
2383
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
2384
|
+
const selected = options.find((option) => option.getAttribute("aria-selected") === "true");
|
|
2385
|
+
selected?.scrollIntoView({ block: "nearest" });
|
|
2386
|
+
if (focusOption) (selected || options[0])?.focus();
|
|
2387
|
+
};
|
|
2388
|
+
const close = (restoreFocus = false) => {
|
|
2389
|
+
menu.setAttribute("hidden", "");
|
|
2390
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
2391
|
+
if (restoreFocus) trigger.focus();
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
trigger.addEventListener("click", () => {
|
|
2395
|
+
if (menu.hasAttribute("hidden")) open();
|
|
2396
|
+
else close();
|
|
2397
|
+
});
|
|
2398
|
+
trigger.addEventListener("keydown", (event) => {
|
|
2399
|
+
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
|
|
2400
|
+
event.preventDefault();
|
|
2401
|
+
open(true);
|
|
2402
|
+
}
|
|
2403
|
+
});
|
|
2404
|
+
menu.addEventListener("keydown", (event) => {
|
|
2405
|
+
if (event.key === "Escape") {
|
|
2406
|
+
event.preventDefault();
|
|
2407
|
+
close(true);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
2411
|
+
event.preventDefault();
|
|
2412
|
+
const current = Math.max(0, options.indexOf(document.activeElement));
|
|
2413
|
+
const target = event.key === "Home"
|
|
2414
|
+
? 0
|
|
2415
|
+
: event.key === "End"
|
|
2416
|
+
? options.length - 1
|
|
2417
|
+
: event.key === "ArrowDown"
|
|
2418
|
+
? Math.min(options.length - 1, current + 1)
|
|
2419
|
+
: Math.max(0, current - 1);
|
|
2420
|
+
options[target]?.focus();
|
|
2421
|
+
});
|
|
2422
|
+
options.forEach((option) => {
|
|
2423
|
+
option.addEventListener("click", () => {
|
|
2424
|
+
state[key] = option.dataset.filterOption;
|
|
2425
|
+
syncUrlState();
|
|
2426
|
+
render();
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
if (!document.documentElement.dataset.filterDismissBound) {
|
|
2432
|
+
document.documentElement.dataset.filterDismissBound = "true";
|
|
2433
|
+
document.addEventListener("pointerdown", (event) => {
|
|
2434
|
+
if (!event.target.closest("[data-filter-dropdown]")) closeFilterDropdowns();
|
|
2435
|
+
});
|
|
2436
|
+
document.addEventListener("keydown", (event) => {
|
|
2437
|
+
if (event.key === "Escape") closeFilterDropdowns();
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const viewMetadata = {
|
|
2443
|
+
overview: {
|
|
2444
|
+
label: "Resumen",
|
|
2445
|
+
description: "Estado de la corrida y señales que requieren atención.",
|
|
2446
|
+
icon: "▣",
|
|
2447
|
+
},
|
|
2448
|
+
executions: {
|
|
2449
|
+
label: "Ejecuciones",
|
|
2450
|
+
description: "Tendencias, historial y comparación entre corridas.",
|
|
2451
|
+
icon: "▶",
|
|
2452
|
+
},
|
|
2453
|
+
quality: {
|
|
2454
|
+
label: "Calidad",
|
|
2455
|
+
description: "Estabilidad, fallos recurrentes y rendimiento histórico.",
|
|
2456
|
+
icon: "◇",
|
|
2457
|
+
},
|
|
2458
|
+
analysis: {
|
|
2459
|
+
label: "Análisis",
|
|
2460
|
+
description: "Bandeja de trabajo para investigar y clasificar fallos.",
|
|
2461
|
+
icon: "!",
|
|
2462
|
+
},
|
|
2463
|
+
tests: {
|
|
2464
|
+
label: "Pruebas",
|
|
2465
|
+
description: "Explorador de casos, pasos, evidencias e historial por Jira.",
|
|
2466
|
+
icon: "☑",
|
|
2467
|
+
},
|
|
2468
|
+
preferences: {
|
|
2469
|
+
label: "Preferencias",
|
|
2470
|
+
description: "Apariencia y configuración local del espacio de trabajo.",
|
|
2471
|
+
icon: "⚙",
|
|
2472
|
+
},
|
|
2473
|
+
};
|
|
2474
|
+
|
|
2475
|
+
function navigateTo(view, push = true) {
|
|
2476
|
+
if (!availableViews.has(view) || view === state.view) return;
|
|
2477
|
+
state.view = view;
|
|
2478
|
+
syncUrlState(push);
|
|
2479
|
+
render();
|
|
2480
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function openPdfExportModal() {
|
|
2484
|
+
const dialog = document.getElementById("pdf-export-modal");
|
|
2485
|
+
if (!dialog) return;
|
|
2486
|
+
dialog.innerHTML = `<div class="pdfExportShell">
|
|
2487
|
+
<header>
|
|
2488
|
+
<div>
|
|
2489
|
+
<p class="eyebrow">Exportación configurable</p>
|
|
2490
|
+
<h2>Armar PDF ejecutivo</h2>
|
|
2491
|
+
<p>Elegí qué información querés incluir. La portada y la identificación de la corrida se agregan siempre.</p>
|
|
2492
|
+
</div>
|
|
2493
|
+
<button type="button" class="modalCloseButton" data-close-pdf aria-label="Cerrar">×</button>
|
|
2494
|
+
</header>
|
|
2495
|
+
<div class="pdfExportToolbar">
|
|
2496
|
+
<span data-pdf-selection-count></span>
|
|
2497
|
+
<div>
|
|
2498
|
+
<button type="button" data-pdf-recommended>Usar selección recomendada</button>
|
|
2499
|
+
<button type="button" data-pdf-all>Seleccionar todas</button>
|
|
2500
|
+
</div>
|
|
2501
|
+
</div>
|
|
2502
|
+
<div class="pdfSectionGrid">
|
|
2503
|
+
${pdfSections.map(([key, label, description, recommended]) => `<label class="pdfSectionOption">
|
|
2504
|
+
<input type="checkbox" name="pdf-section" value="${escapeHtml(key)}" ${recommended ? "checked" : ""} />
|
|
2505
|
+
<span class="pdfSectionCheck" aria-hidden="true">✓</span>
|
|
2506
|
+
<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description)}</small></span>
|
|
2507
|
+
</label>`).join("")}
|
|
2508
|
+
</div>
|
|
2509
|
+
<footer>
|
|
2510
|
+
<span class="pdfExportFeedback" data-pdf-feedback role="status"></span>
|
|
2511
|
+
<button type="button" class="secondaryButton" data-close-pdf>Cancelar</button>
|
|
2512
|
+
<button type="button" class="primaryButton" data-generate-pdf>Generar PDF</button>
|
|
2513
|
+
</footer>
|
|
2514
|
+
</div>`;
|
|
2515
|
+
|
|
2516
|
+
const checkboxes = [...dialog.querySelectorAll('input[name="pdf-section"]')];
|
|
2517
|
+
const generateButton = dialog.querySelector("[data-generate-pdf]");
|
|
2518
|
+
const updateSelection = () => {
|
|
2519
|
+
const selected = checkboxes.filter((checkbox) => checkbox.checked).length;
|
|
2520
|
+
dialog.querySelector("[data-pdf-selection-count]").textContent =
|
|
2521
|
+
`${selected} de ${checkboxes.length} secciones seleccionadas`;
|
|
2522
|
+
generateButton.disabled = selected === 0;
|
|
2523
|
+
};
|
|
2524
|
+
checkboxes.forEach((checkbox) => checkbox.addEventListener("change", updateSelection));
|
|
2525
|
+
dialog.querySelectorAll("[data-close-pdf]").forEach((button) =>
|
|
2526
|
+
button.addEventListener("click", () => dialog.close()));
|
|
2527
|
+
dialog.querySelector("[data-pdf-recommended]").addEventListener("click", () => {
|
|
2528
|
+
checkboxes.forEach((checkbox) => {
|
|
2529
|
+
checkbox.checked = Boolean(pdfSections.find(([key]) => key === checkbox.value)?.[3]);
|
|
2530
|
+
});
|
|
2531
|
+
updateSelection();
|
|
2532
|
+
});
|
|
2533
|
+
dialog.querySelector("[data-pdf-all]").addEventListener("click", () => {
|
|
2534
|
+
checkboxes.forEach((checkbox) => { checkbox.checked = true; });
|
|
2535
|
+
updateSelection();
|
|
2536
|
+
});
|
|
2537
|
+
generateButton.addEventListener("click", async () => {
|
|
2538
|
+
const selected = checkboxes.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);
|
|
2539
|
+
const feedback = dialog.querySelector("[data-pdf-feedback]");
|
|
2540
|
+
feedback.textContent = "";
|
|
2541
|
+
const downloaded = await downloadExecutivePdf(selected, generateButton);
|
|
2542
|
+
if (downloaded) dialog.close();
|
|
2543
|
+
else feedback.textContent = "No se pudo generar el PDF. Verificá que Elmulo Serve siga activo.";
|
|
2544
|
+
});
|
|
2545
|
+
updateSelection();
|
|
2546
|
+
dialog.showModal();
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
async function downloadExecutivePdf(sections, triggerButton) {
|
|
2550
|
+
const button = triggerButton || document.getElementById("export-executive-pdf");
|
|
2551
|
+
if (!button || button.disabled) return;
|
|
2552
|
+
const originalLabel = button.innerHTML;
|
|
2553
|
+
button.disabled = true;
|
|
2554
|
+
button.innerHTML = "<span aria-hidden=\"true\">↓</span> Generando PDF...";
|
|
2555
|
+
button.removeAttribute("title");
|
|
2556
|
+
const sectionQuery = encodeURIComponent(sections.join(","));
|
|
2557
|
+
const candidates = [...new Set([
|
|
2558
|
+
new URL(`/api/export/executive.pdf?sections=${sectionQuery}`, window.location.href).href,
|
|
2559
|
+
`http://127.0.0.1:4178/api/export/executive.pdf?sections=${sectionQuery}`,
|
|
2560
|
+
])];
|
|
2561
|
+
let lastError = null;
|
|
2562
|
+
|
|
2563
|
+
for (const candidate of candidates) {
|
|
2564
|
+
try {
|
|
2565
|
+
const response = await fetch(candidate);
|
|
2566
|
+
if (!response.ok) {
|
|
2567
|
+
const body = await response.text();
|
|
2568
|
+
let message = `La exportación respondió ${response.status}.`;
|
|
2569
|
+
try {
|
|
2570
|
+
message = JSON.parse(body).error || message;
|
|
2571
|
+
} catch {
|
|
2572
|
+
// El servidor puede responder HTML si no es Elmulo Serve.
|
|
2573
|
+
}
|
|
2574
|
+
throw new Error(message);
|
|
2575
|
+
}
|
|
2576
|
+
const contentType = response.headers.get("content-type") || "";
|
|
2577
|
+
if (!contentType.includes("application/pdf")) {
|
|
2578
|
+
throw new Error("Elmulo Serve no devolvió un archivo PDF.");
|
|
2579
|
+
}
|
|
2580
|
+
const disposition = response.headers.get("content-disposition") || "";
|
|
2581
|
+
const filename = disposition.match(/filename="([^"]+)"/i)?.[1] ||
|
|
2582
|
+
`elmulo-ejecutivo-${run.environment}-${run.id}.pdf`;
|
|
2583
|
+
const blob = await response.blob();
|
|
2584
|
+
const downloadUrl = URL.createObjectURL(blob);
|
|
2585
|
+
const link = document.createElement("a");
|
|
2586
|
+
link.href = downloadUrl;
|
|
2587
|
+
link.download = filename;
|
|
2588
|
+
document.body.appendChild(link);
|
|
2589
|
+
link.click();
|
|
2590
|
+
link.remove();
|
|
2591
|
+
URL.revokeObjectURL(downloadUrl);
|
|
2592
|
+
button.innerHTML = "<span aria-hidden=\"true\">✓</span> PDF descargado";
|
|
2593
|
+
button.innerHTML = originalLabel;
|
|
2594
|
+
button.disabled = false;
|
|
2595
|
+
return true;
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
lastError = error;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
button.innerHTML = "<span aria-hidden=\"true\">!</span> No se pudo exportar";
|
|
2602
|
+
button.title = `${lastError?.message || "Error desconocido"} Abrí Elmulo mediante yarn elmulo:serve.`;
|
|
2603
|
+
button.disabled = false;
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
function sidebarMarkup() {
|
|
2608
|
+
const failed = run.tests.filter((test) => effectiveTestStatus(test) === "failed");
|
|
2609
|
+
const pendingAnalysis = failed.filter((test) => !annotationFor(test).comment).length;
|
|
2610
|
+
const badges = {
|
|
2611
|
+
analysis: pendingAnalysis,
|
|
2612
|
+
tests: run.tests.length,
|
|
2613
|
+
};
|
|
2614
|
+
return `<aside class="sidebar ${state.sidebarCollapsed ? "collapsed" : ""}" aria-label="Navegación principal">
|
|
2615
|
+
<div class="sidebarBrand">
|
|
2616
|
+
<span class="brandMark">E</span>
|
|
2617
|
+
<span class="sidebarBrandText">
|
|
2618
|
+
<strong>Elmulo Reporter</strong>
|
|
2619
|
+
<small>V2 beta</small>
|
|
2620
|
+
</span>
|
|
2621
|
+
<button id="sidebar-toggle" class="sidebarToggle" type="button" aria-label="${state.sidebarCollapsed ? "Expandir" : "Colapsar"} menú lateral" title="${state.sidebarCollapsed ? "Expandir" : "Colapsar"} menú">${state.sidebarCollapsed ? "›" : "‹"}</button>
|
|
2622
|
+
</div>
|
|
2623
|
+
<nav class="sidebarNav">
|
|
2624
|
+
${Object.entries(viewMetadata).map(([key, item]) => `<button
|
|
2625
|
+
type="button"
|
|
2626
|
+
class="sidebarLink ${state.view === key ? "active" : ""}"
|
|
2627
|
+
data-nav-view="${key}"
|
|
2628
|
+
aria-current="${state.view === key ? "page" : "false"}"
|
|
2629
|
+
title="${escapeHtml(item.label)}"
|
|
2630
|
+
>
|
|
2631
|
+
<span class="sidebarIcon" aria-hidden="true">${item.icon}</span>
|
|
2632
|
+
<span class="sidebarLabel">${escapeHtml(item.label)}</span>
|
|
2633
|
+
${badges[key] ? `<span class="sidebarBadge">${escapeHtml(badges[key])}</span>` : ""}
|
|
2634
|
+
</button>`).join("")}
|
|
2635
|
+
</nav>
|
|
2636
|
+
<div class="sidebarContext">
|
|
2637
|
+
<span class="environmentDot" aria-hidden="true"></span>
|
|
2638
|
+
<span class="sidebarLabel"><strong>${escapeHtml(String(run.environment || "").toUpperCase())}</strong><small>${escapeHtml(formatDate(run.startedAt))}</small></span>
|
|
2639
|
+
</div>
|
|
2640
|
+
</aside>`;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
function overviewLaunchpadMarkup() {
|
|
2644
|
+
const failed = run.tests.filter((test) => effectiveTestStatus(test) === "failed");
|
|
2645
|
+
const withoutComment = failed.filter((test) => !annotationFor(test).comment).length;
|
|
2646
|
+
return `<section class="overviewLaunchpad" aria-labelledby="overview-next-title">
|
|
2647
|
+
<header>
|
|
2648
|
+
<div><p class="eyebrow">Explorar</p><h2 id="overview-next-title">Continuá el análisis</h2></div>
|
|
2649
|
+
<span>Cada área conserva el ambiente, los filtros y la prueba seleccionada.</span>
|
|
2650
|
+
</header>
|
|
2651
|
+
<div class="overviewLaunchGrid">
|
|
2652
|
+
<button type="button" data-nav-view="executions"><span>▶</span><strong>Ejecuciones</strong><small>${escapeHtml(run.trends?.totalRuns || 0)} corridas disponibles</small></button>
|
|
2653
|
+
<button type="button" data-nav-view="quality"><span>◇</span><strong>Calidad histórica</strong><small>Estabilidad, recurrencia y tiempos</small></button>
|
|
2654
|
+
<button type="button" data-nav-view="analysis"><span>!</span><strong>Análisis pendiente</strong><small>${withoutComment} fallos sin comentario</small></button>
|
|
2655
|
+
<button type="button" data-nav-view="tests"><span>☑</span><strong>Explorar pruebas</strong><small>${run.tests.length} ejecuciones en la corrida</small></button>
|
|
2656
|
+
</div>
|
|
2657
|
+
</section>`;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function bindWorkspaceNavigation() {
|
|
2661
|
+
document.querySelectorAll("[data-nav-view]").forEach((button) => {
|
|
2662
|
+
button.addEventListener("click", () => navigateTo(button.dataset.navView));
|
|
2663
|
+
});
|
|
2664
|
+
document.getElementById("sidebar-toggle")?.addEventListener("click", () => {
|
|
2665
|
+
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
2666
|
+
savePreferences();
|
|
2667
|
+
render();
|
|
2668
|
+
});
|
|
2669
|
+
document.getElementById("mobile-menu-toggle")?.addEventListener("click", () => {
|
|
2670
|
+
document.querySelector(".sidebar")?.classList.toggle("mobileOpen");
|
|
2671
|
+
});
|
|
2672
|
+
document.getElementById("export-executive-pdf")?.addEventListener(
|
|
2673
|
+
"click",
|
|
2674
|
+
openPdfExportModal,
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function render() {
|
|
2679
|
+
applyAppearance();
|
|
2680
|
+
const activeView = viewMetadata[state.view];
|
|
2681
|
+
app.innerHTML = `<div class="appFrame">
|
|
2682
|
+
${sidebarMarkup()}
|
|
2683
|
+
<div class="workspaceShell">
|
|
2684
|
+
<nav class="topbar" aria-label="Contexto del reporte">
|
|
2685
|
+
<button id="mobile-menu-toggle" class="mobileMenuToggle" type="button" aria-label="Abrir menú principal">☰</button>
|
|
2686
|
+
<div class="pageContext">
|
|
2687
|
+
<span>${escapeHtml(activeView.label)}</span>
|
|
2688
|
+
<small>${escapeHtml(activeView.description)}</small>
|
|
2689
|
+
</div>
|
|
2690
|
+
<span id="save-status" class="saveStatus" role="status" aria-live="polite"></span>
|
|
2691
|
+
<span class="environmentPill">${escapeHtml(run.environment)}</span>
|
|
2692
|
+
</nav>
|
|
2693
|
+
|
|
2694
|
+
<main class="appShell">
|
|
2695
|
+
<section class="workspaceView" data-view="overview" ${state.view === "overview" ? "" : "hidden"}>
|
|
2696
|
+
<header class="hero">
|
|
2697
|
+
<div>
|
|
2698
|
+
<p class="eyebrow">Corrida ${run.status === "passed" ? "exitosa" : "fallida"} · ${escapeHtml(String(run.environment || "").toUpperCase())}</p>
|
|
2699
|
+
<h1>${run.status === "passed"
|
|
2700
|
+
? `${run.counts?.passed || 0} pruebas exitosas`
|
|
2701
|
+
: `${run.counts?.failed || 0} fallos requieren revisión`}</h1>
|
|
2702
|
+
<p>${escapeHtml(run.projectName)} · ${escapeHtml(run.tagExpression || "Sin filtro de tags")}</p>
|
|
2703
|
+
<div class="heroMeta">
|
|
2704
|
+
<span>${escapeHtml(formatDate(run.startedAt))}</span>
|
|
2705
|
+
<span>${escapeHtml(run.browser.name)} ${escapeHtml(run.browser.version)}</span>
|
|
2706
|
+
<span>Cypress ${escapeHtml(run.cypressVersion)}</span>
|
|
2707
|
+
<span>${escapeHtml(formatDuration(run.durationMs))}</span>
|
|
2708
|
+
${run.source?.branch ? `<span>Rama ${escapeHtml(run.source.branch)}</span>` : ""}
|
|
2709
|
+
${run.source?.commit ? `<span>Commit ${escapeHtml(String(run.source.commit).slice(0, 8))}</span>` : ""}
|
|
2710
|
+
${run.source?.pipelineId ? `<span>Pipeline ${escapeHtml(run.source.pipelineId)}</span>` : ""}
|
|
2711
|
+
</div>
|
|
2712
|
+
</div>
|
|
2713
|
+
<div class="heroActionStack">
|
|
2714
|
+
<div class="heroRunState">
|
|
2715
|
+
<span>Duración</span>
|
|
2716
|
+
<strong>${escapeHtml(formatDuration(run.durationMs))}</strong>
|
|
2717
|
+
<small>${escapeHtml(run.tests.length)} ejecuciones</small>
|
|
2718
|
+
</div>
|
|
2719
|
+
<button
|
|
2720
|
+
id="export-executive-pdf"
|
|
2721
|
+
class="exportPdfButton"
|
|
2722
|
+
type="button"
|
|
2723
|
+
title="Descargar un resumen ejecutivo de la corrida actual"
|
|
2724
|
+
><span aria-hidden="true">↓</span> Exportar PDF ejecutivo</button>
|
|
2725
|
+
</div>
|
|
2726
|
+
</header>
|
|
2727
|
+
|
|
2728
|
+
<section id="summary-grid" class="summaryGrid" aria-label="Resumen">
|
|
2729
|
+
${summaryMarkup()}
|
|
2730
|
+
</section>
|
|
2731
|
+
<section id="execution-status-chart" class="statusDistribution" aria-label="Ejecuciones por estado">
|
|
2732
|
+
${executionStatusChartMarkup()}
|
|
2733
|
+
</section>
|
|
2734
|
+
${overviewLaunchpadMarkup()}
|
|
2735
|
+
</section>
|
|
2736
|
+
|
|
2737
|
+
<section class="workspaceView" data-view="analysis" ${state.view === "analysis" ? "" : "hidden"}>
|
|
2738
|
+
<header class="workspaceHeader">
|
|
2739
|
+
<div><p class="eyebrow">Bandeja operativa</p><h1>Análisis</h1><p>Priorizá los resultados que todavía necesitan diagnóstico o seguimiento.</p></div>
|
|
2740
|
+
<button type="button" data-nav-view="tests">Abrir explorador de pruebas</button>
|
|
2741
|
+
</header>
|
|
2742
|
+
<section class="attentionSection" aria-labelledby="attention-title">
|
|
2743
|
+
<header><div><p class="eyebrow">Requiere atención</p><h2 id="attention-title">Bandeja de análisis</h2></div><span>Seleccioná una tarjeta para investigar</span></header>
|
|
2744
|
+
<div class="attentionGrid">${attentionMarkup()}</div>
|
|
2745
|
+
</section>
|
|
2746
|
+
<div class="analysisGuidance">
|
|
2747
|
+
<article><span>1</span><strong>Seleccioná una cola</strong><small>Filtrá fallos, inestables o pruebas sin seguimiento.</small></article>
|
|
2748
|
+
<article><span>2</span><strong>Investigá la evidencia</strong><small>Revisá pasos, error, intentos, screenshots y logs.</small></article>
|
|
2749
|
+
<article><span>3</span><strong>Documentá la decisión</strong><small>Clasificá el resultado y asociá comentario o ticket.</small></article>
|
|
2750
|
+
</div>
|
|
2751
|
+
</section>
|
|
2752
|
+
|
|
2753
|
+
<section class="workspaceView" data-view="executions" ${state.view === "executions" ? "" : "hidden"}>
|
|
2754
|
+
<header class="workspaceHeader">
|
|
2755
|
+
<div><p class="eyebrow">Corridas</p><h1>Ejecuciones</h1><p>Consultá tendencias, recuperá resultados históricos y compará cambios.</p></div>
|
|
2756
|
+
</header>
|
|
2757
|
+
<details class="panel trendPanel">
|
|
2758
|
+
<summary class="trendSummary">
|
|
2759
|
+
<div>
|
|
2760
|
+
<h2>Tendencia</h2>
|
|
2761
|
+
<label class="trendEnvironmentPicker">
|
|
2762
|
+
<span>Últimas ejecuciones del ambiente</span>
|
|
2763
|
+
<select
|
|
2764
|
+
id="trend-environment"
|
|
2765
|
+
aria-label="Seleccionar ambiente para las tendencias"
|
|
2766
|
+
>
|
|
2767
|
+
<option value="qa" ${state.trendEnvironment === "qa" ? "selected" : ""}>QA</option>
|
|
2768
|
+
<option value="sandbox" ${state.trendEnvironment === "sandbox" ? "selected" : ""}>Sandbox</option>
|
|
2769
|
+
</select>
|
|
2770
|
+
</label>
|
|
2771
|
+
</div>
|
|
2772
|
+
<div class="trendSummaryMeta">
|
|
2773
|
+
<span id="trend-run-count" class="environmentPill">${escapeHtml(run.trends?.totalRuns ?? run.trends?.runs?.length ?? 0)} corridas</span>
|
|
2774
|
+
<span class="trendChevron" aria-hidden="true">›</span>
|
|
2775
|
+
</div>
|
|
2776
|
+
</summary>
|
|
2777
|
+
<div class="trendExpandedContent">
|
|
2778
|
+
<div class="trendHeaderControls">
|
|
2779
|
+
<label>Estado
|
|
2780
|
+
<select id="trend-status" aria-label="Filtrar gráfico de tendencia por estado">
|
|
2781
|
+
<option value="total" ${state.trendStatus === "total" ? "selected" : ""}>Todos</option>
|
|
2782
|
+
<option value="success_rate" ${state.trendStatus === "success_rate" ? "selected" : ""}>Tasa de éxito</option>
|
|
2783
|
+
<option value="passed" ${state.trendStatus === "passed" ? "selected" : ""}>Exitosos</option>
|
|
2784
|
+
<option value="failed" ${state.trendStatus === "failed" ? "selected" : ""}>Fallidos</option>
|
|
2785
|
+
<option value="environment_error" ${state.trendStatus === "environment_error" ? "selected" : ""}>Errores de ambiente</option>
|
|
2786
|
+
<option value="precondition_error" ${state.trendStatus === "precondition_error" ? "selected" : ""}>Errores de precondición</option>
|
|
2787
|
+
<option value="outdated_test" ${state.trendStatus === "outdated_test" ? "selected" : ""}>Pruebas desactualizadas</option>
|
|
2788
|
+
<option value="reported" ${state.trendStatus === "reported" ? "selected" : ""}>Reportado</option>
|
|
2789
|
+
</select>
|
|
2790
|
+
</label>
|
|
2791
|
+
<label>Período
|
|
2792
|
+
<select id="trend-limit" aria-label="Cantidad de corridas para la tendencia">
|
|
2793
|
+
${[10, 20, 50].map((limit) => `<option value="${limit}" ${state.trendLimit === limit ? "selected" : ""}>Últimas ${limit}</option>`).join("")}
|
|
2794
|
+
</select>
|
|
2795
|
+
</label>
|
|
2796
|
+
</div>
|
|
2797
|
+
<div id="trend-content" aria-live="polite">${renderTrend()}</div>
|
|
2798
|
+
<div id="trend-history" aria-live="polite">${renderHistoricalRun()}</div>
|
|
2799
|
+
</div>
|
|
2800
|
+
</details>
|
|
2801
|
+
</section>
|
|
2802
|
+
|
|
2803
|
+
<section class="workspaceView" data-view="quality" ${state.view === "quality" ? "" : "hidden"}>
|
|
2804
|
+
<header class="workspaceHeader">
|
|
2805
|
+
<div><p class="eyebrow">Observabilidad</p><h1>Calidad</h1><p>Analizá estabilidad, recurrencia y rendimiento con el historial consolidado.</p></div>
|
|
2806
|
+
</header>
|
|
2807
|
+
<details class="panel qualityPanel">
|
|
2808
|
+
<summary><div><h2>Calidad histórica</h2><p>Inestabilidad, tasa de fallos y rendimiento.</p></div><span class="trendChevron" aria-hidden="true">›</span></summary>
|
|
2809
|
+
<div id="quality-content">${renderQualityPanel()}</div>
|
|
2810
|
+
</details>
|
|
2811
|
+
</section>
|
|
2812
|
+
|
|
2813
|
+
<section class="workspaceView executionsContinuation" data-view="executions" ${state.view === "executions" ? "" : "hidden"}>
|
|
2814
|
+
<details class="panel featuresPanel" open>
|
|
2815
|
+
<summary><div><h2>Features</h2><p>Resultados de la corrida actual agrupados por Feature.</p></div><span class="trendChevron" aria-hidden="true">›</span></summary>
|
|
2816
|
+
${featureDistributionMarkup()}
|
|
2817
|
+
</details>
|
|
2818
|
+
<details class="panel comparisonPanel">
|
|
2819
|
+
<summary><div><h2>Comparar corridas</h2><p>Nuevos fallos, pruebas resueltas y cambios relevantes.</p></div><span class="trendChevron" aria-hidden="true">›</span></summary>
|
|
2820
|
+
<div class="comparisonControls">
|
|
2821
|
+
<label>Base<select id="compare-base"><option value="">Elegir corrida</option>${(run.trends?.runs || []).map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(`${formatDate(item.started_at)} · ${String(item.environment || "").toUpperCase()} · ${item.failed || 0} fallos`)}</option>`).join("")}</select></label>
|
|
2822
|
+
<label>Objetivo<select id="compare-target"><option value="">Elegir corrida</option>${(run.trends?.runs || []).map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(`${formatDate(item.started_at)} · ${String(item.environment || "").toUpperCase()} · ${item.failed || 0} fallos`)}</option>`).join("")}</select></label>
|
|
2823
|
+
<button id="compare-runs" type="button">Comparar</button>
|
|
2824
|
+
</div>
|
|
2825
|
+
<div id="comparison-results" aria-live="polite"></div>
|
|
2826
|
+
</details>
|
|
2827
|
+
</section>
|
|
2828
|
+
|
|
2829
|
+
<section class="workspaceView" data-view="tests" ${state.view === "tests" ? "" : "hidden"}>
|
|
2830
|
+
<header class="workspaceHeader">
|
|
2831
|
+
<div><p class="eyebrow">Explorador</p><h1>Pruebas</h1><p>Filtrá casos y examiná pasos, errores, intentos, evidencias e historial por Jira.</p></div>
|
|
2832
|
+
</header>
|
|
2833
|
+
<section class="filters" aria-label="Filtros de pruebas">
|
|
2834
|
+
<div class="field filterSearch"><label for="search">Buscar <span class="helpTip" title="Usá jira:, status:, tag:, ticket: o spec: para búsquedas precisas">?</span></label><input id="search" type="search" value="${escapeHtml(state.search)}" placeholder="Prueba, Jira, ticket… Ej.: jira:FONLP06-2156" /></div>
|
|
2835
|
+
${filterDropdownMarkup("status", "Estado", [
|
|
2836
|
+
["all", "Todos"],
|
|
2837
|
+
["failed", "Fallidos"],
|
|
2838
|
+
["passed", "Exitosos"],
|
|
2839
|
+
["skipped", "Omitidos"],
|
|
2840
|
+
["other_errors", "Otros errores"],
|
|
2841
|
+
["environment_error", "Error de ambiente"],
|
|
2842
|
+
["precondition_error", "Error de precondición"],
|
|
2843
|
+
["outdated_test", "Prueba desactualizada"],
|
|
2844
|
+
["reported", "Reportado"],
|
|
2845
|
+
], state.status)}
|
|
2846
|
+
${filterDropdownMarkup("spec", "Spec", [
|
|
2847
|
+
["all", "Todos"],
|
|
2848
|
+
...specs.map((spec) => [spec, spec.split("/").at(-1)]),
|
|
2849
|
+
], state.spec)}
|
|
2850
|
+
${filterDropdownMarkup("tag", "Tag", [
|
|
2851
|
+
["all", "Todos"],
|
|
2852
|
+
...tags.map((tag) => [tag, tag]),
|
|
2853
|
+
], state.tag)}
|
|
2854
|
+
${filterDropdownMarkup(
|
|
2855
|
+
"flaky",
|
|
2856
|
+
"Inestabilidad",
|
|
2857
|
+
[
|
|
2858
|
+
["all", "Todas"],
|
|
2859
|
+
["yes", "Sólo inestables"],
|
|
2860
|
+
["no", "Excluir inestables"],
|
|
2861
|
+
],
|
|
2862
|
+
state.flaky,
|
|
2863
|
+
' <span class="helpTip" title="Flaky: falló en un intento y terminó exitosa después de un retry">?</span>',
|
|
2864
|
+
)}
|
|
2865
|
+
${filterDropdownMarkup("sort", "Ordenar", [
|
|
2866
|
+
["severity", "Severidad"],
|
|
2867
|
+
["duration", "Duración"],
|
|
2868
|
+
["name", "Nombre"],
|
|
2869
|
+
["history", "Reintentos"],
|
|
2870
|
+
], state.sort)}
|
|
2871
|
+
<div id="active-filters" class="activeFilters">${activeFilterChips()}</div>
|
|
2872
|
+
</section>
|
|
2873
|
+
|
|
2874
|
+
<details class="bulkPanel">
|
|
2875
|
+
<summary>Modificación masiva · <span id="selected-count">${state.bulkSelected.size} seleccionadas</span></summary>
|
|
2876
|
+
<div class="bulkFields">
|
|
2877
|
+
<label>Estado<select id="bulk-status"><option value="environment_error">Error de ambiente</option><option value="precondition_error">Error de precondición</option><option value="outdated_test">Prueba desactualizada</option><option value="reported">Reportado</option><option value="failed">Fallido</option></select></label>
|
|
2878
|
+
<label>Comentario<input id="bulk-comment" maxlength="20000" /></label>
|
|
2879
|
+
<label>Ticket<input id="bulk-ticket" maxlength="2000" /></label>
|
|
2880
|
+
<button id="apply-bulk" type="button" ${state.bulkSelected.size ? "" : "disabled"}>Revisar cambios</button>
|
|
2881
|
+
<span id="bulk-feedback" aria-live="polite"></span>
|
|
2882
|
+
</div>
|
|
2883
|
+
</details>
|
|
2884
|
+
|
|
2885
|
+
<section class="resultsLayout">
|
|
2886
|
+
<article class="testList">
|
|
2887
|
+
<header class="listHeader">
|
|
2888
|
+
<div><h2>Pruebas</h2><p id="visible-count"></p></div>
|
|
2889
|
+
<label class="selectVisible"><input id="select-visible" type="checkbox" /> Seleccionar visibles</label>
|
|
2890
|
+
</header>
|
|
2891
|
+
<div id="test-rows" class="testRows"></div>
|
|
2892
|
+
</article>
|
|
2893
|
+
<article id="test-detail" class="testDetail" aria-live="polite"></article>
|
|
2894
|
+
</section>
|
|
2895
|
+
</section>
|
|
2896
|
+
|
|
2897
|
+
<section class="workspaceView" data-view="preferences" ${state.view === "preferences" ? "" : "hidden"}>
|
|
2898
|
+
<header class="workspaceHeader">
|
|
2899
|
+
<div><p class="eyebrow">Espacio de trabajo</p><h1>Preferencias</h1><p>Personalizá la lectura del reporte en este navegador.</p></div>
|
|
2900
|
+
</header>
|
|
2901
|
+
<div class="preferencesGrid">
|
|
2902
|
+
<article class="preferenceCard">
|
|
2903
|
+
<span class="preferenceIcon" aria-hidden="true">A</span>
|
|
2904
|
+
<div><h2>Analista</h2><p>Nombre que quedará asociado a comentarios y cambios de estado.</p></div>
|
|
2905
|
+
<label>Nombre
|
|
2906
|
+
<input id="actor-name" value="${escapeHtml(state.actor)}" maxlength="120" aria-label="Nombre del analista" />
|
|
2907
|
+
</label>
|
|
2908
|
+
</article>
|
|
2909
|
+
<article class="preferenceCard">
|
|
2910
|
+
<span class="preferenceIcon" aria-hidden="true">≡</span>
|
|
2911
|
+
<div><h2>Densidad</h2><p>Ajustá cuánto contenido se muestra simultáneamente.</p></div>
|
|
2912
|
+
<label>Densidad de información
|
|
2913
|
+
<select id="density" aria-label="Densidad de información">
|
|
2914
|
+
<option value="comfortable" ${state.density === "comfortable" ? "selected" : ""}>Cómoda</option>
|
|
2915
|
+
<option value="compact" ${state.density === "compact" ? "selected" : ""}>Compacta</option>
|
|
2916
|
+
<option value="dense" ${state.density === "dense" ? "selected" : ""}>Muy compacta</option>
|
|
2917
|
+
</select>
|
|
2918
|
+
</label>
|
|
2919
|
+
</article>
|
|
2920
|
+
<article class="preferenceCard">
|
|
2921
|
+
<span class="preferenceIcon" aria-hidden="true">${state.theme === "dark" ? "☀" : "☾"}</span>
|
|
2922
|
+
<div><h2>Apariencia</h2><p>Alterná entre el tema claro y oscuro manteniendo el contraste.</p></div>
|
|
2923
|
+
<button id="theme-toggle" class="preferenceAction" type="button">${state.theme === "dark" ? "Usar tema claro" : "Usar tema oscuro"}</button>
|
|
2924
|
+
</article>
|
|
2925
|
+
<article class="preferenceCard">
|
|
2926
|
+
<span class="preferenceIcon" aria-hidden="true">↔</span>
|
|
2927
|
+
<div><h2>Menú lateral</h2><p>El menú recuerda automáticamente si preferís verlo expandido o compacto.</p></div>
|
|
2928
|
+
<button type="button" class="preferenceAction" data-toggle-sidebar>${state.sidebarCollapsed ? "Expandir menú" : "Colapsar menú"}</button>
|
|
2929
|
+
</article>
|
|
2930
|
+
</div>
|
|
2931
|
+
</section>
|
|
2932
|
+
|
|
2933
|
+
<footer class="footer">Elmulo Reporter V2 beta · Esquema ${escapeHtml(run.schemaVersion || 3)} · Run ${escapeHtml(run.id)} · Generado ${escapeHtml(formatDate(run.generatedAt))}</footer>
|
|
2934
|
+
</main>
|
|
2935
|
+
</div>
|
|
2936
|
+
</div>
|
|
2937
|
+
<dialog id="test-history-modal" class="testHistoryModal" aria-label="Historial de estados de la prueba"></dialog>
|
|
2938
|
+
<dialog id="reuse-confirmation-modal" class="reuseConfirmationModal" aria-label="Confirmar reutilización de estado"></dialog>
|
|
2939
|
+
<dialog id="bulk-confirmation-modal" class="reuseConfirmationModal" aria-label="Confirmar modificación masiva"></dialog>
|
|
2940
|
+
<dialog id="pdf-export-modal" class="pdfExportModal" aria-label="Seleccionar secciones del PDF ejecutivo"></dialog>`;
|
|
2941
|
+
|
|
2942
|
+
document.getElementById("search").addEventListener("input", (event) => {
|
|
2943
|
+
state.search = event.target.value;
|
|
2944
|
+
syncUrlState();
|
|
2945
|
+
renderList();
|
|
2946
|
+
const activeFilters = document.getElementById("active-filters");
|
|
2947
|
+
if (activeFilters) activeFilters.innerHTML = activeFilterChips();
|
|
2948
|
+
bindFilterChipEvents();
|
|
2949
|
+
});
|
|
2950
|
+
bindTestFilterDropdowns();
|
|
2951
|
+
const trendEnvironment = document.getElementById("trend-environment");
|
|
2952
|
+
for (const eventName of ["click", "pointerdown", "keydown"]) {
|
|
2953
|
+
trendEnvironment.addEventListener(eventName, (event) => {
|
|
2954
|
+
event.stopPropagation();
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
trendEnvironment.addEventListener("change", (event) => {
|
|
2958
|
+
loadTrendEnvironment(event.target.value);
|
|
2959
|
+
});
|
|
2960
|
+
document.getElementById("trend-status").addEventListener("change", (event) => {
|
|
2961
|
+
state.trendStatus = event.target.value;
|
|
2962
|
+
renderTrendArea();
|
|
2963
|
+
});
|
|
2964
|
+
document.getElementById("trend-limit").addEventListener("change", (event) => {
|
|
2965
|
+
state.trendLimit = Number(event.target.value);
|
|
2966
|
+
syncUrlState();
|
|
2967
|
+
loadTrendEnvironment(state.trendEnvironment);
|
|
2968
|
+
});
|
|
2969
|
+
document.getElementById("actor-name").addEventListener("change", (event) => {
|
|
2970
|
+
state.actor = event.target.value.trim() || "Usuario local";
|
|
2971
|
+
localStorage.setItem(actorStorageKey, state.actor);
|
|
2972
|
+
});
|
|
2973
|
+
document.getElementById("density").addEventListener("change", (event) => {
|
|
2974
|
+
state.density = event.target.value;
|
|
2975
|
+
savePreferences();
|
|
2976
|
+
applyAppearance();
|
|
2977
|
+
});
|
|
2978
|
+
document.getElementById("theme-toggle").addEventListener("click", () => {
|
|
2979
|
+
state.theme = state.theme === "dark" ? "light" : "dark";
|
|
2980
|
+
savePreferences();
|
|
2981
|
+
render();
|
|
2982
|
+
});
|
|
2983
|
+
document.querySelector("[data-toggle-sidebar]")?.addEventListener("click", () => {
|
|
2984
|
+
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
2985
|
+
savePreferences();
|
|
2986
|
+
render();
|
|
2987
|
+
});
|
|
2988
|
+
document.getElementById("compare-runs").addEventListener("click", compareSelectedRuns);
|
|
2989
|
+
document.getElementById("apply-bulk").addEventListener("click", openBulkConfirmation);
|
|
2990
|
+
document.getElementById("select-visible").addEventListener("change", (event) => {
|
|
2991
|
+
for (const test of filteredTests()) {
|
|
2992
|
+
if (event.target.checked) state.bulkSelected.add(test.id);
|
|
2993
|
+
else state.bulkSelected.delete(test.id);
|
|
2994
|
+
}
|
|
2995
|
+
renderList();
|
|
2996
|
+
});
|
|
2997
|
+
bindWorkspaceNavigation();
|
|
2998
|
+
bindFilterChipEvents();
|
|
2999
|
+
bindAttentionEvents();
|
|
3000
|
+
bindQualityEvents();
|
|
3001
|
+
renderList();
|
|
3002
|
+
renderTrendArea();
|
|
3003
|
+
renderSaveStatus();
|
|
3004
|
+
loadAnalytics();
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
window.addEventListener("popstate", () => {
|
|
3008
|
+
const view = new URLSearchParams(window.location.search).get("view") || "overview";
|
|
3009
|
+
state.view = availableViews.has(view) ? view : "overview";
|
|
3010
|
+
render();
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
render();
|
|
3014
|
+
})();
|