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