@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/server.cjs
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const http = require("node:http");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const {
|
|
5
|
+
buildQualityAnalytics,
|
|
6
|
+
buildHtml,
|
|
7
|
+
buildTrends,
|
|
8
|
+
compareRuns,
|
|
9
|
+
loadAnnotationAudit,
|
|
10
|
+
loadAnnotations,
|
|
11
|
+
loadRunResults,
|
|
12
|
+
loadTestHistory,
|
|
13
|
+
openDatabase,
|
|
14
|
+
persistAnnotation,
|
|
15
|
+
VALID_ANNOTATION_STATUSES,
|
|
16
|
+
} = require("./finalize.cjs");
|
|
17
|
+
const { atomicWriteFile, writeJson } = require("./core.cjs");
|
|
18
|
+
const {
|
|
19
|
+
PDF_SECTIONS,
|
|
20
|
+
buildExecutivePdf,
|
|
21
|
+
} = require("./executive-pdf.cjs");
|
|
22
|
+
const { normalizeActor, sanitizeText } = require("./security.cjs");
|
|
23
|
+
const { createRerunManager } = require("./rerun.cjs");
|
|
24
|
+
|
|
25
|
+
const MIME_TYPES = {
|
|
26
|
+
".css": "text/css; charset=utf-8",
|
|
27
|
+
".html": "text/html; charset=utf-8",
|
|
28
|
+
".js": "text/javascript; charset=utf-8",
|
|
29
|
+
".json": "application/json; charset=utf-8",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".jpg": "image/jpeg",
|
|
32
|
+
".jpeg": "image/jpeg",
|
|
33
|
+
".webp": "image/webp",
|
|
34
|
+
".mp4": "video/mp4",
|
|
35
|
+
".pdf": "application/pdf",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function sendJson(response, statusCode, value, options = {}) {
|
|
39
|
+
const headers = {
|
|
40
|
+
"Cache-Control": "no-store",
|
|
41
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
42
|
+
};
|
|
43
|
+
if (options.allowOrigin) {
|
|
44
|
+
headers["Access-Control-Allow-Origin"] = options.allowOrigin;
|
|
45
|
+
headers.Vary = "Origin";
|
|
46
|
+
} else if (options.allowAnyOrigin !== false) {
|
|
47
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
48
|
+
}
|
|
49
|
+
response.writeHead(statusCode, {
|
|
50
|
+
...headers,
|
|
51
|
+
});
|
|
52
|
+
response.end(JSON.stringify(value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function trustedMutationOrigin(request, allowedOrigins = new Set()) {
|
|
56
|
+
const origin = String(request.headers.origin || "").trim();
|
|
57
|
+
const fetchSite = String(request.headers["sec-fetch-site"] || "").toLowerCase();
|
|
58
|
+
if (!origin) return fetchSite !== "cross-site";
|
|
59
|
+
try {
|
|
60
|
+
const originUrl = new URL(origin);
|
|
61
|
+
return (
|
|
62
|
+
originUrl.protocol === "http:" &&
|
|
63
|
+
allowedOrigins.has(originUrl.origin.toLowerCase())
|
|
64
|
+
);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function localMutationOrigins(host, port) {
|
|
71
|
+
const normalizedHost = String(host || "").trim().toLowerCase();
|
|
72
|
+
const origins = new Set();
|
|
73
|
+
if (["127.0.0.1", "localhost", "0.0.0.0", "::1", "[::1]"].includes(normalizedHost)) {
|
|
74
|
+
origins.add(`http://127.0.0.1:${port}`);
|
|
75
|
+
origins.add(`http://localhost:${port}`);
|
|
76
|
+
}
|
|
77
|
+
return origins;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function persistDatabase(databasePath, database) {
|
|
81
|
+
atomicWriteFile(databasePath, Buffer.from(database.export()));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readRequestJson(request) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
|
|
89
|
+
request.on("data", (chunk) => {
|
|
90
|
+
size += chunk.length;
|
|
91
|
+
if (size > 1_000_000) {
|
|
92
|
+
reject(new Error("El seguimiento supera el tamaño permitido."));
|
|
93
|
+
request.destroy();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
chunks.push(chunk);
|
|
97
|
+
});
|
|
98
|
+
request.on("end", () => {
|
|
99
|
+
try {
|
|
100
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
|
|
101
|
+
} catch {
|
|
102
|
+
reject(new Error("El cuerpo de la solicitud no es JSON válido."));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
request.on("error", reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function safeStaticPath(reportDir, pathname) {
|
|
110
|
+
const relativePath = decodeURIComponent(pathname)
|
|
111
|
+
.replace(/^\/+/, "") || "index.html";
|
|
112
|
+
const candidate = path.resolve(reportDir, relativePath);
|
|
113
|
+
const rootPrefix = `${path.resolve(reportDir)}${path.sep}`;
|
|
114
|
+
return candidate.startsWith(rootPrefix) ? candidate : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function serveReport(options = {}) {
|
|
118
|
+
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
119
|
+
const outputDir = path.resolve(
|
|
120
|
+
projectRoot,
|
|
121
|
+
options.outputDir || process.env.ELMULO_OUTPUT_DIR || "elmulo-results",
|
|
122
|
+
);
|
|
123
|
+
const reportDir = path.join(outputDir, "report");
|
|
124
|
+
const databasePath = path.join(outputDir, "elmulo.sqlite");
|
|
125
|
+
const dataPath = path.join(reportDir, "data.json");
|
|
126
|
+
const indexPath = path.join(reportDir, "index.html");
|
|
127
|
+
const host = options.host || "127.0.0.1";
|
|
128
|
+
const port = Number(
|
|
129
|
+
options.port !== undefined
|
|
130
|
+
? options.port
|
|
131
|
+
: process.env.ELMULO_PORT || 4178,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!fs.existsSync(indexPath) || !fs.existsSync(dataPath)) {
|
|
135
|
+
throw new Error("No existe un reporte generado. Ejecutá yarn elmulo:generate.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let writeQueue = Promise.resolve();
|
|
139
|
+
const rerunManager = createRerunManager(projectRoot, options.rerun || {});
|
|
140
|
+
let allowedMutationOrigins = new Set();
|
|
141
|
+
|
|
142
|
+
const server = http.createServer(async (request, response) => {
|
|
143
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || host}`);
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
request.method === "OPTIONS" &&
|
|
147
|
+
/^\/api\/runs\/[^/]+\/rerun$/.test(url.pathname)
|
|
148
|
+
) {
|
|
149
|
+
if (!trustedMutationOrigin(request, allowedMutationOrigins)) {
|
|
150
|
+
sendJson(response, 403, { error: "Origen no autorizado para reejecutar." }, {
|
|
151
|
+
allowAnyOrigin: false,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const origin = String(request.headers.origin || "");
|
|
156
|
+
response.writeHead(204, {
|
|
157
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
158
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
159
|
+
...(origin
|
|
160
|
+
? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
|
|
161
|
+
: {}),
|
|
162
|
+
"Cache-Control": "no-store",
|
|
163
|
+
});
|
|
164
|
+
response.end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
|
169
|
+
response.writeHead(204, {
|
|
170
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
171
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
172
|
+
"Access-Control-Allow-Origin": "*",
|
|
173
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
174
|
+
"Cache-Control": "no-store",
|
|
175
|
+
});
|
|
176
|
+
response.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
181
|
+
sendJson(response, 200, {
|
|
182
|
+
ok: true,
|
|
183
|
+
version: "2.0.0-beta.5",
|
|
184
|
+
schemaVersion: 3,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (request.method === "GET" && url.pathname === "/api/export/executive.pdf") {
|
|
190
|
+
const requestedSections = url.searchParams.has("sections")
|
|
191
|
+
? url.searchParams.get("sections").split(",").filter(Boolean)
|
|
192
|
+
: undefined;
|
|
193
|
+
if (requestedSections && (
|
|
194
|
+
!requestedSections.length ||
|
|
195
|
+
requestedSections.some((section) => !PDF_SECTIONS.includes(section))
|
|
196
|
+
)) {
|
|
197
|
+
sendJson(response, 400, { error: "La selección de secciones no es válida." });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const run = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
202
|
+
if (requestedSections?.some((section) => ["recurrentFailures", "slowTests"].includes(section))) {
|
|
203
|
+
const database = await openDatabase(databasePath);
|
|
204
|
+
run.analytics = buildQualityAnalytics(database, run.environment);
|
|
205
|
+
database.close();
|
|
206
|
+
}
|
|
207
|
+
const pdf = await buildExecutivePdf(run, { sections: requestedSections });
|
|
208
|
+
const environment = String(run.environment || "ambiente")
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.replace(/[^a-z0-9_-]/g, "");
|
|
211
|
+
const runId = String(run.id || "corrida").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
212
|
+
response.writeHead(200, {
|
|
213
|
+
"Access-Control-Allow-Origin": "*",
|
|
214
|
+
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
215
|
+
"Cache-Control": "no-store",
|
|
216
|
+
"Content-Disposition": `attachment; filename="elmulo-ejecutivo-${environment}-${runId}.pdf"`,
|
|
217
|
+
"Content-Length": pdf.length,
|
|
218
|
+
"Content-Type": "application/pdf",
|
|
219
|
+
});
|
|
220
|
+
response.end(pdf);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
sendJson(response, 500, { error: error.message });
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (request.method === "GET" && url.pathname === "/api/analytics") {
|
|
228
|
+
const environment = String(url.searchParams.get("environment") || "sandbox");
|
|
229
|
+
try {
|
|
230
|
+
const database = await openDatabase(databasePath);
|
|
231
|
+
const analytics = buildQualityAnalytics(database, environment);
|
|
232
|
+
database.close();
|
|
233
|
+
sendJson(response, 200, analytics);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
sendJson(response, 500, { error: error.message });
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (request.method === "GET" && url.pathname === "/api/compare") {
|
|
241
|
+
const base = String(url.searchParams.get("base") || "");
|
|
242
|
+
const target = String(url.searchParams.get("target") || "");
|
|
243
|
+
if (![base, target].every((id) => /^[a-zA-Z0-9_-]+$/.test(id))) {
|
|
244
|
+
sendJson(response, 400, { error: "Corridas inválidas para comparar." });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const database = await openDatabase(databasePath);
|
|
249
|
+
const comparison = compareRuns(database, base, target);
|
|
250
|
+
database.close();
|
|
251
|
+
sendJson(response, 200, comparison);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
sendJson(response, 500, { error: error.message });
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (request.method === "GET" && url.pathname === "/api/audit") {
|
|
259
|
+
const runId = String(url.searchParams.get("runId") || "");
|
|
260
|
+
const testId = String(url.searchParams.get("testId") || "");
|
|
261
|
+
if (![runId, testId].every((id) => /^[a-zA-Z0-9_-]+$/.test(id))) {
|
|
262
|
+
sendJson(response, 400, { error: "Identidad de prueba inválida." });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const database = await openDatabase(databasePath);
|
|
267
|
+
const events = loadAnnotationAudit(database, runId, testId);
|
|
268
|
+
database.close();
|
|
269
|
+
sendJson(response, 200, { events });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
sendJson(response, 500, { error: error.message });
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (request.method === "GET" && url.pathname === "/api/trends") {
|
|
277
|
+
const environment = String(url.searchParams.get("environment") || "")
|
|
278
|
+
.trim()
|
|
279
|
+
.toLowerCase();
|
|
280
|
+
if (!["qa", "sandbox"].includes(environment)) {
|
|
281
|
+
sendJson(response, 400, { error: "Ambiente inválido." });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const database = await openDatabase(databasePath);
|
|
286
|
+
const trends = buildTrends(
|
|
287
|
+
database,
|
|
288
|
+
{ environment },
|
|
289
|
+
url.searchParams.get("limit"),
|
|
290
|
+
);
|
|
291
|
+
database.close();
|
|
292
|
+
sendJson(response, 200, trends);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
sendJson(response, 500, { error: error.message });
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const rerunMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/rerun$/);
|
|
300
|
+
if (
|
|
301
|
+
rerunMatch &&
|
|
302
|
+
(request.method === "GET" || request.method === "POST")
|
|
303
|
+
) {
|
|
304
|
+
const runId = decodeURIComponent(rerunMatch[1]);
|
|
305
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) {
|
|
306
|
+
sendJson(response, 400, { error: "Identificador de corrida inválido." });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
if (request.method === "GET") {
|
|
311
|
+
const status = rerunManager.get(runId);
|
|
312
|
+
if (!status) {
|
|
313
|
+
sendJson(response, 404, { error: "La corrida no fue reejecutada en esta sesión." });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
sendJson(response, 200, status);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!trustedMutationOrigin(request, allowedMutationOrigins)) {
|
|
321
|
+
sendJson(response, 403, { error: "Origen no autorizado para reejecutar." }, {
|
|
322
|
+
allowAnyOrigin: false,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const database = await openDatabase(databasePath);
|
|
328
|
+
const runResults = loadRunResults(database, runId);
|
|
329
|
+
database.close();
|
|
330
|
+
if (!runResults) {
|
|
331
|
+
sendJson(response, 404, { error: "Corrida no encontrada." });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const requestOrigin = String(request.headers.origin || "");
|
|
335
|
+
sendJson(response, 202, rerunManager.start(runResults), {
|
|
336
|
+
...(requestOrigin ? { allowOrigin: requestOrigin } : {}),
|
|
337
|
+
allowAnyOrigin: false,
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const requestOrigin = String(request.headers.origin || "");
|
|
341
|
+
sendJson(response, 409, { error: error.message }, request.method === "POST"
|
|
342
|
+
? {
|
|
343
|
+
...(requestOrigin ? { allowOrigin: requestOrigin } : {}),
|
|
344
|
+
allowAnyOrigin: false,
|
|
345
|
+
}
|
|
346
|
+
: {});
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (request.method === "GET" && url.pathname.startsWith("/api/runs/")) {
|
|
352
|
+
const runId = decodeURIComponent(url.pathname.slice("/api/runs/".length));
|
|
353
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) {
|
|
354
|
+
sendJson(response, 400, { error: "Identificador de corrida inválido." });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const database = await openDatabase(databasePath);
|
|
359
|
+
const runResults = loadRunResults(database, runId);
|
|
360
|
+
database.close();
|
|
361
|
+
if (!runResults) {
|
|
362
|
+
sendJson(response, 404, { error: "Corrida no encontrada." });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
sendJson(response, 200, {
|
|
366
|
+
...runResults,
|
|
367
|
+
rerun: rerunManager.describe(runResults),
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
sendJson(response, 500, { error: error.message });
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (request.method === "GET" && url.pathname === "/api/test-history") {
|
|
376
|
+
const jiraId = String(url.searchParams.get("jiraId") || "")
|
|
377
|
+
.replace(/^@/, "")
|
|
378
|
+
.toUpperCase();
|
|
379
|
+
if (!/^[A-Z][A-Z0-9]+-\d+$/.test(jiraId)) {
|
|
380
|
+
sendJson(response, 400, { error: "Identificador Jira inválido." });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const database = await openDatabase(databasePath);
|
|
385
|
+
const history = loadTestHistory(database, jiraId);
|
|
386
|
+
database.close();
|
|
387
|
+
sendJson(response, 200, { jiraId, history });
|
|
388
|
+
} catch (error) {
|
|
389
|
+
sendJson(response, 500, { error: error.message });
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (request.method === "POST" && url.pathname === "/api/history-annotations") {
|
|
395
|
+
try {
|
|
396
|
+
const body = await readRequestJson(request);
|
|
397
|
+
writeQueue = writeQueue.catch(() => {}).then(async () => {
|
|
398
|
+
const jiraId = String(body.jiraId || "").replace(/^@/, "").toUpperCase();
|
|
399
|
+
if (!/^[A-Z][A-Z0-9]+-\d+$/.test(jiraId)) {
|
|
400
|
+
throw new Error("Identificador Jira inválido.");
|
|
401
|
+
}
|
|
402
|
+
if (!VALID_ANNOTATION_STATUSES.has(body.status)) {
|
|
403
|
+
throw new Error("La clasificación seleccionada no es válida.");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const activeRun = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
407
|
+
const database = await openDatabase(databasePath);
|
|
408
|
+
const history = loadTestHistory(database, jiraId);
|
|
409
|
+
const target = history.find(
|
|
410
|
+
(entry) =>
|
|
411
|
+
entry.run_id === body.runId && entry.test_id === body.testId,
|
|
412
|
+
);
|
|
413
|
+
if (!target) {
|
|
414
|
+
database.close();
|
|
415
|
+
throw new Error("La ejecución no pertenece al historial Jira indicado.");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
persistAnnotation(database, {
|
|
419
|
+
runId: target.run_id,
|
|
420
|
+
testId: target.test_id,
|
|
421
|
+
status: body.status,
|
|
422
|
+
comment: String(body.comment || "").slice(0, 20_000),
|
|
423
|
+
ticket: String(body.ticket || "").slice(0, 2_000),
|
|
424
|
+
actor: normalizeActor(body.actor),
|
|
425
|
+
source: "history_reuse",
|
|
426
|
+
});
|
|
427
|
+
activeRun.annotations = loadAnnotations(database, activeRun.id);
|
|
428
|
+
activeRun.trends = buildTrends(database, activeRun);
|
|
429
|
+
const updatedHistory = loadTestHistory(database, jiraId);
|
|
430
|
+
persistDatabase(databasePath, database);
|
|
431
|
+
database.close();
|
|
432
|
+
|
|
433
|
+
writeJson(dataPath, activeRun);
|
|
434
|
+
fs.writeFileSync(indexPath, buildHtml(activeRun), "utf8");
|
|
435
|
+
return {
|
|
436
|
+
jiraId,
|
|
437
|
+
history: updatedHistory,
|
|
438
|
+
annotations: activeRun.annotations,
|
|
439
|
+
trends: activeRun.trends,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
sendJson(response, 200, await writeQueue);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
sendJson(response, 400, { error: error.message });
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (request.method === "POST" && url.pathname === "/api/annotations") {
|
|
450
|
+
try {
|
|
451
|
+
const body = await readRequestJson(request);
|
|
452
|
+
writeQueue = writeQueue.catch(() => {}).then(async () => {
|
|
453
|
+
const run = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
454
|
+
const test = run.tests.find((candidate) => candidate.id === body.testId);
|
|
455
|
+
if (body.runId !== run.id || !test) {
|
|
456
|
+
throw new Error("La prueba no pertenece al reporte activo.");
|
|
457
|
+
}
|
|
458
|
+
if (!VALID_ANNOTATION_STATUSES.has(body.status)) {
|
|
459
|
+
throw new Error("La clasificación seleccionada no es válida.");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const database = await openDatabase(databasePath);
|
|
463
|
+
const updatedAt = persistAnnotation(database, {
|
|
464
|
+
runId: run.id,
|
|
465
|
+
testId: test.id,
|
|
466
|
+
status: body.status,
|
|
467
|
+
comment: String(body.comment || "").slice(0, 20_000),
|
|
468
|
+
ticket: String(body.ticket || "").slice(0, 2_000),
|
|
469
|
+
actor: normalizeActor(body.actor),
|
|
470
|
+
source: sanitizeText(body.source || "manual", 80),
|
|
471
|
+
});
|
|
472
|
+
run.annotations = loadAnnotations(database, run.id);
|
|
473
|
+
run.trends = buildTrends(database, run);
|
|
474
|
+
persistDatabase(databasePath, database);
|
|
475
|
+
database.close();
|
|
476
|
+
|
|
477
|
+
writeJson(dataPath, run);
|
|
478
|
+
fs.writeFileSync(indexPath, buildHtml(run), "utf8");
|
|
479
|
+
return {
|
|
480
|
+
annotation: run.annotations[test.id],
|
|
481
|
+
annotations: run.annotations,
|
|
482
|
+
trends: run.trends,
|
|
483
|
+
updatedAt,
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
sendJson(response, 200, await writeQueue);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
sendJson(response, 400, { error: error.message });
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (request.method === "POST" && url.pathname === "/api/bulk-annotations") {
|
|
495
|
+
try {
|
|
496
|
+
const body = await readRequestJson(request);
|
|
497
|
+
writeQueue = writeQueue.catch(() => {}).then(async () => {
|
|
498
|
+
const run = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
499
|
+
const testIds = [...new Set(Array.isArray(body.testIds) ? body.testIds : [])]
|
|
500
|
+
.filter((id) => run.tests.some((test) => test.id === id))
|
|
501
|
+
.slice(0, 250);
|
|
502
|
+
if (!testIds.length) throw new Error("Seleccioná al menos una prueba.");
|
|
503
|
+
if (!VALID_ANNOTATION_STATUSES.has(body.status)) {
|
|
504
|
+
throw new Error("La clasificación seleccionada no es válida.");
|
|
505
|
+
}
|
|
506
|
+
const database = await openDatabase(databasePath);
|
|
507
|
+
for (const testId of testIds) {
|
|
508
|
+
persistAnnotation(database, {
|
|
509
|
+
runId: run.id,
|
|
510
|
+
testId,
|
|
511
|
+
status: body.status,
|
|
512
|
+
comment: body.comment,
|
|
513
|
+
ticket: body.ticket,
|
|
514
|
+
actor: body.actor,
|
|
515
|
+
source: "bulk",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
run.annotations = loadAnnotations(database, run.id);
|
|
519
|
+
run.trends = buildTrends(database, run);
|
|
520
|
+
persistDatabase(databasePath, database);
|
|
521
|
+
database.close();
|
|
522
|
+
writeJson(dataPath, run);
|
|
523
|
+
fs.writeFileSync(indexPath, buildHtml(run), "utf8");
|
|
524
|
+
return { updated: testIds.length, annotations: run.annotations, trends: run.trends };
|
|
525
|
+
});
|
|
526
|
+
sendJson(response, 200, await writeQueue);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
sendJson(response, 400, { error: error.message });
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
534
|
+
sendJson(response, 405, { error: "Método no permitido." });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const filePath = safeStaticPath(reportDir, url.pathname);
|
|
539
|
+
if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
540
|
+
sendJson(response, 404, { error: "Recurso no encontrado." });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
response.writeHead(200, {
|
|
545
|
+
"Cache-Control": "no-store",
|
|
546
|
+
"Content-Type": MIME_TYPES[path.extname(filePath).toLowerCase()] ||
|
|
547
|
+
"application/octet-stream",
|
|
548
|
+
});
|
|
549
|
+
if (request.method === "HEAD") {
|
|
550
|
+
response.end();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
fs.createReadStream(filePath).pipe(response);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
await new Promise((resolve, reject) => {
|
|
557
|
+
server.once("error", reject);
|
|
558
|
+
server.listen(port, host, resolve);
|
|
559
|
+
});
|
|
560
|
+
const effectivePort = server.address().port;
|
|
561
|
+
allowedMutationOrigins = localMutationOrigins(host, effectivePort);
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
server,
|
|
565
|
+
url: `http://${host}:${effectivePort}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
module.exports = { localMutationOrigins, serveReport, trustedMutationOrigin };
|