@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/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alejandrojca/elmulo-reporter",
|
|
3
|
+
"version": "2.0.0-beta.5",
|
|
4
|
+
"description": "Reporter independiente para ejecuciones de Cypress con dashboard, historial SQLite y evidencias.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "plugin.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./plugin.cjs",
|
|
9
|
+
"./cypress": "./plugin.cjs",
|
|
10
|
+
"./support": "./support.ts",
|
|
11
|
+
"./cli": "./cli.cjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"elmulo": "cli.cjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"assets/",
|
|
19
|
+
"cli.cjs",
|
|
20
|
+
"core.cjs",
|
|
21
|
+
"executive-pdf.cjs",
|
|
22
|
+
"finalize.cjs",
|
|
23
|
+
"plugin.cjs",
|
|
24
|
+
"rerun.cjs",
|
|
25
|
+
"security.cjs",
|
|
26
|
+
"server.cjs",
|
|
27
|
+
"support.ts",
|
|
28
|
+
"VERSION.json",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.cjs",
|
|
33
|
+
"demo": "node cli.cjs demo",
|
|
34
|
+
"serve": "node cli.cjs serve",
|
|
35
|
+
"pack:check": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"pdfkit": "0.17.2",
|
|
39
|
+
"sql.js": "1.13.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"cypress": ">=12"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"cypress": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/alejandrojca/Elmulo-Reporter.git"
|
|
55
|
+
},
|
|
56
|
+
"license": "UNLICENSED",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/alejandrojca/Elmulo-Reporter/issues"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/alejandrojca/Elmulo-Reporter#readme",
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@napi-rs/canvas": "^0.1.100",
|
|
63
|
+
"pdfjs-dist": "^4.10.38"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/plugin.cjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const {
|
|
4
|
+
createRunId,
|
|
5
|
+
ensureDir,
|
|
6
|
+
normalizeCypressResults,
|
|
7
|
+
safeFileName,
|
|
8
|
+
stableId,
|
|
9
|
+
writeJson,
|
|
10
|
+
} = require("./core.cjs");
|
|
11
|
+
const {
|
|
12
|
+
isAllowedAttachment,
|
|
13
|
+
sanitizeLogEntry,
|
|
14
|
+
sanitizeText,
|
|
15
|
+
} = require("./security.cjs");
|
|
16
|
+
const { normalizeRerunArgs } = require("./rerun.cjs");
|
|
17
|
+
|
|
18
|
+
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function registerElmuloReporter(on, config, options = {}) {
|
|
21
|
+
const captureHttp =
|
|
22
|
+
options.captureHttp !== false &&
|
|
23
|
+
String(process.env.ELMULO_CAPTURE_HTTP || "true").toLowerCase() !== "false";
|
|
24
|
+
config.env = {
|
|
25
|
+
...(config.env || {}),
|
|
26
|
+
elmulo: true,
|
|
27
|
+
elmuloCaptureHttp: captureHttp,
|
|
28
|
+
};
|
|
29
|
+
const projectRoot = config.projectRoot || process.cwd();
|
|
30
|
+
const outputDir = path.resolve(
|
|
31
|
+
projectRoot,
|
|
32
|
+
options.outputDir || process.env.ELMULO_OUTPUT_DIR || "elmulo-results",
|
|
33
|
+
);
|
|
34
|
+
let session = null;
|
|
35
|
+
const executionScript =
|
|
36
|
+
options.rerunScript ||
|
|
37
|
+
process.env.npm_lifecycle_event ||
|
|
38
|
+
process.env.ELMULO_RERUN_SCRIPT ||
|
|
39
|
+
"";
|
|
40
|
+
const executionArgs = normalizeRerunArgs(
|
|
41
|
+
options.rerunArgs ?? process.env.ELMULO_RERUN_ARGS_JSON,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function startSession(details = {}) {
|
|
45
|
+
const startedAt = details.startedTestsAt || new Date().toISOString();
|
|
46
|
+
const runId = createRunId(startedAt);
|
|
47
|
+
const runDir = path.join(outputDir, "runs", runId);
|
|
48
|
+
ensureDir(runDir);
|
|
49
|
+
session = { runId, runDir, startedAt };
|
|
50
|
+
writeJson(path.join(runDir, "session.json"), {
|
|
51
|
+
runId,
|
|
52
|
+
startedAt,
|
|
53
|
+
environment:
|
|
54
|
+
options.environment ||
|
|
55
|
+
config.env?.ENVIRONMENT ||
|
|
56
|
+
process.env.CYPRESS_ENVIRONMENT ||
|
|
57
|
+
"unknown",
|
|
58
|
+
tagExpression: config.env?.TAGS || process.env.CYPRESS_TAGS || "",
|
|
59
|
+
lifecycle: "running",
|
|
60
|
+
reporterVersion: "2.0.0-beta.5",
|
|
61
|
+
execution: executionScript
|
|
62
|
+
? { runner: "npm", script: executionScript, args: executionArgs }
|
|
63
|
+
: null,
|
|
64
|
+
});
|
|
65
|
+
fs.writeFileSync(path.join(outputDir, "latest-run.txt"), runId, "utf8");
|
|
66
|
+
return session;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
on("before:run", (details) => {
|
|
70
|
+
startSession(details);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
on("after:run", (results) => {
|
|
74
|
+
if (!session) startSession(results);
|
|
75
|
+
|
|
76
|
+
const normalized = normalizeCypressResults(results, {
|
|
77
|
+
projectRoot,
|
|
78
|
+
runId: session.runId,
|
|
79
|
+
startedAt: session.startedAt,
|
|
80
|
+
environment:
|
|
81
|
+
options.environment ||
|
|
82
|
+
config.env?.ENVIRONMENT ||
|
|
83
|
+
process.env.CYPRESS_ENVIRONMENT ||
|
|
84
|
+
"unknown",
|
|
85
|
+
tagExpression: config.env?.TAGS || process.env.CYPRESS_TAGS || "",
|
|
86
|
+
projectName: config.projectName || "acceptance-tests",
|
|
87
|
+
execution: executionScript
|
|
88
|
+
? { runner: "npm", script: executionScript, args: executionArgs }
|
|
89
|
+
: null,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const logManifestPath = path.join(session.runDir, "logs", "manifest.json");
|
|
93
|
+
if (fs.existsSync(logManifestPath)) {
|
|
94
|
+
const manifest = JSON.parse(fs.readFileSync(logManifestPath, "utf8"));
|
|
95
|
+
const logsByTest = new Map(
|
|
96
|
+
manifest.map((entry) => [entry.testKey, entry.logs || []]),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
for (const test of normalized.tests) {
|
|
100
|
+
const exactKey = test.titlePath.join(" › ");
|
|
101
|
+
test.logs =
|
|
102
|
+
logsByTest.get(exactKey) ||
|
|
103
|
+
logsByTest.get(test.title) ||
|
|
104
|
+
[];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const httpManifestPath = path.join(session.runDir, "http", "manifest.json");
|
|
109
|
+
if (fs.existsSync(httpManifestPath)) {
|
|
110
|
+
const manifest = JSON.parse(fs.readFileSync(httpManifestPath, "utf8"));
|
|
111
|
+
const httpByTest = new Map(
|
|
112
|
+
manifest.map((entry) => [entry.testKey, entry.exchanges || []]),
|
|
113
|
+
);
|
|
114
|
+
for (const test of normalized.tests) {
|
|
115
|
+
const exactKey = test.titlePath.join(" › ");
|
|
116
|
+
test.http =
|
|
117
|
+
httpByTest.get(exactKey) ||
|
|
118
|
+
httpByTest.get(test.title) ||
|
|
119
|
+
[];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const attachmentManifestPath = path.join(
|
|
124
|
+
session.runDir,
|
|
125
|
+
"attachments",
|
|
126
|
+
"manifest.json",
|
|
127
|
+
);
|
|
128
|
+
if (fs.existsSync(attachmentManifestPath)) {
|
|
129
|
+
const manifest = JSON.parse(
|
|
130
|
+
fs.readFileSync(attachmentManifestPath, "utf8"),
|
|
131
|
+
);
|
|
132
|
+
normalized.attachments = manifest;
|
|
133
|
+
const attachmentsByTest = new Map();
|
|
134
|
+
for (const attachment of manifest) {
|
|
135
|
+
if (!attachmentsByTest.has(attachment.testKey)) {
|
|
136
|
+
attachmentsByTest.set(attachment.testKey, []);
|
|
137
|
+
}
|
|
138
|
+
attachmentsByTest.get(attachment.testKey).push(attachment);
|
|
139
|
+
}
|
|
140
|
+
for (const test of normalized.tests) {
|
|
141
|
+
test.attachments =
|
|
142
|
+
attachmentsByTest.get(test.titlePath.join(" › ")) ||
|
|
143
|
+
attachmentsByTest.get(test.title) ||
|
|
144
|
+
[];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeJson(path.join(session.runDir, "run.raw.json"), normalized);
|
|
149
|
+
writeJson(path.join(session.runDir, "run.json"), normalized);
|
|
150
|
+
return null;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
on("task", {
|
|
154
|
+
"elmulo:recordLogs"({ testKey, logs }) {
|
|
155
|
+
if (!session) startSession();
|
|
156
|
+
const logsDir = path.join(session.runDir, "logs");
|
|
157
|
+
ensureDir(logsDir);
|
|
158
|
+
const manifestPath = path.join(logsDir, "manifest.json");
|
|
159
|
+
const manifest = fs.existsSync(manifestPath)
|
|
160
|
+
? JSON.parse(fs.readFileSync(manifestPath, "utf8"))
|
|
161
|
+
: [];
|
|
162
|
+
const entry = {
|
|
163
|
+
testKey: String(testKey || ""),
|
|
164
|
+
logs: Array.isArray(logs)
|
|
165
|
+
? logs.slice(-500).map(sanitizeLogEntry)
|
|
166
|
+
: [],
|
|
167
|
+
};
|
|
168
|
+
const existingIndex = manifest.findIndex(
|
|
169
|
+
(item) => item.testKey === entry.testKey,
|
|
170
|
+
);
|
|
171
|
+
if (existingIndex >= 0) manifest[existingIndex] = entry;
|
|
172
|
+
else manifest.push(entry);
|
|
173
|
+
writeJson(manifestPath, manifest);
|
|
174
|
+
return null;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
"elmulo:recordHttp"({ testKey, exchanges }) {
|
|
178
|
+
if (!session) startSession();
|
|
179
|
+
const httpDir = path.join(session.runDir, "http");
|
|
180
|
+
ensureDir(httpDir);
|
|
181
|
+
const manifestPath = path.join(httpDir, "manifest.json");
|
|
182
|
+
const manifest = fs.existsSync(manifestPath)
|
|
183
|
+
? JSON.parse(fs.readFileSync(manifestPath, "utf8"))
|
|
184
|
+
: [];
|
|
185
|
+
const entry = {
|
|
186
|
+
testKey: String(testKey || ""),
|
|
187
|
+
// Deliberately unredacted: exact values are required for diagnosis.
|
|
188
|
+
exchanges: Array.isArray(exchanges) ? exchanges : [],
|
|
189
|
+
};
|
|
190
|
+
const existingIndex = manifest.findIndex(
|
|
191
|
+
(item) => item.testKey === entry.testKey,
|
|
192
|
+
);
|
|
193
|
+
if (existingIndex >= 0) manifest[existingIndex] = entry;
|
|
194
|
+
else manifest.push(entry);
|
|
195
|
+
writeJson(manifestPath, manifest);
|
|
196
|
+
return null;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
"elmulo:attach"({ testKey, name, mimeType, content, encoding }) {
|
|
200
|
+
if (!session) startSession();
|
|
201
|
+
if (!isAllowedAttachment(mimeType)) {
|
|
202
|
+
throw new Error(`Tipo de adjunto no permitido: ${mimeType}`);
|
|
203
|
+
}
|
|
204
|
+
const attachmentsDir = path.join(session.runDir, "attachments");
|
|
205
|
+
ensureDir(attachmentsDir);
|
|
206
|
+
const id = stableId(testKey, name, Date.now().toString()).slice(0, 12);
|
|
207
|
+
const extension =
|
|
208
|
+
mimeType === "application/json"
|
|
209
|
+
? ".json"
|
|
210
|
+
: mimeType === "text/plain"
|
|
211
|
+
? ".txt"
|
|
212
|
+
: "";
|
|
213
|
+
const filename = `${id}-${safeFileName(name)}${extension}`;
|
|
214
|
+
const filePath = path.join(attachmentsDir, filename);
|
|
215
|
+
const serializedContent =
|
|
216
|
+
typeof content === "string"
|
|
217
|
+
? sanitizeText(content, MAX_ATTACHMENT_BYTES)
|
|
218
|
+
: JSON.stringify(content, null, 2);
|
|
219
|
+
const byteLength = Buffer.byteLength(serializedContent, "utf8");
|
|
220
|
+
if (byteLength > MAX_ATTACHMENT_BYTES) {
|
|
221
|
+
throw new Error("El adjunto supera el límite de 5 MB.");
|
|
222
|
+
}
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
filePath,
|
|
225
|
+
serializedContent,
|
|
226
|
+
encoding === "base64" ? "base64" : "utf8",
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const manifestPath = path.join(attachmentsDir, "manifest.json");
|
|
230
|
+
const manifest = fs.existsSync(manifestPath)
|
|
231
|
+
? JSON.parse(fs.readFileSync(manifestPath, "utf8"))
|
|
232
|
+
: [];
|
|
233
|
+
const attachment = {
|
|
234
|
+
id,
|
|
235
|
+
testKey: String(testKey || ""),
|
|
236
|
+
name: String(name || "Adjunto"),
|
|
237
|
+
mimeType: mimeType || "text/plain",
|
|
238
|
+
path: `attachments/${filename}`,
|
|
239
|
+
};
|
|
240
|
+
manifest.push(attachment);
|
|
241
|
+
writeJson(manifestPath, manifest);
|
|
242
|
+
return attachment;
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return config;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = { registerElmuloReporter };
|
package/rerun.cjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { spawn } = require("node:child_process");
|
|
4
|
+
|
|
5
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed"]);
|
|
6
|
+
|
|
7
|
+
function normalizeRerunArgs(value) {
|
|
8
|
+
if (value === undefined || value === null || value === "") return null;
|
|
9
|
+
let parsed = value;
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(value);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error("ELMULO_RERUN_ARGS_JSON debe contener un array JSON válido.");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(parsed) || parsed.length > 100) {
|
|
18
|
+
throw new Error("Los argumentos de reejecución deben ser un array de hasta 100 valores.");
|
|
19
|
+
}
|
|
20
|
+
return parsed.map((argument) => {
|
|
21
|
+
if (!["string", "number", "boolean"].includes(typeof argument)) {
|
|
22
|
+
throw new Error("Cada argumento de reejecución debe ser texto, número o booleano.");
|
|
23
|
+
}
|
|
24
|
+
const normalized = String(argument);
|
|
25
|
+
if (normalized.includes("\0") || normalized.length > 4_000) {
|
|
26
|
+
throw new Error("Un argumento histórico no es seguro para reejecutar.");
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function npmCliCandidates(projectRoot, options = {}) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
options.npmCliPath,
|
|
35
|
+
process.env.npm_execpath,
|
|
36
|
+
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
|
37
|
+
process.env.npm_config_prefix
|
|
38
|
+
? path.join(process.env.npm_config_prefix, "node_modules", "npm", "bin", "npm-cli.js")
|
|
39
|
+
: "",
|
|
40
|
+
];
|
|
41
|
+
const pathEntries = String(process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
42
|
+
const npmNames = process.platform === "win32" ? ["npm.cmd", "npm"] : ["npm"];
|
|
43
|
+
for (const pathEntry of pathEntries) {
|
|
44
|
+
for (const npmName of npmNames) {
|
|
45
|
+
const npmPath = path.join(pathEntry, npmName);
|
|
46
|
+
if (!fs.existsSync(npmPath)) continue;
|
|
47
|
+
try {
|
|
48
|
+
const resolved = fs.realpathSync(npmPath);
|
|
49
|
+
if (/npm-cli\.(?:c?js)$/i.test(resolved)) candidates.push(resolved);
|
|
50
|
+
} catch {
|
|
51
|
+
// Ignore broken PATH entries and continue with deterministic candidates.
|
|
52
|
+
}
|
|
53
|
+
candidates.push(
|
|
54
|
+
path.join(path.dirname(npmPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
candidates.push(
|
|
59
|
+
path.join(projectRoot, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
60
|
+
);
|
|
61
|
+
return candidates.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveNpmCli(projectRoot, options = {}) {
|
|
65
|
+
if (options.npmCliPath) {
|
|
66
|
+
const configuredPath = path.resolve(options.npmCliPath);
|
|
67
|
+
if (
|
|
68
|
+
/npm-cli\.(?:c?js)$/i.test(configuredPath) &&
|
|
69
|
+
fs.existsSync(configuredPath) &&
|
|
70
|
+
fs.statSync(configuredPath).isFile()
|
|
71
|
+
) {
|
|
72
|
+
return configuredPath;
|
|
73
|
+
}
|
|
74
|
+
throw new Error("La ruta configurada para npm-cli.js no es válida.");
|
|
75
|
+
}
|
|
76
|
+
for (const candidate of npmCliCandidates(projectRoot, options)) {
|
|
77
|
+
const resolved = path.resolve(candidate);
|
|
78
|
+
if (
|
|
79
|
+
/npm-cli\.(?:c?js)$/i.test(resolved) &&
|
|
80
|
+
fs.existsSync(resolved) &&
|
|
81
|
+
fs.statSync(resolved).isFile()
|
|
82
|
+
) {
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(
|
|
87
|
+
"No se encontró npm-cli.js. Iniciá Elmulo desde npm o configurá una instalación válida de npm.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readPackageScripts(projectRoot) {
|
|
92
|
+
const packagePath = path.join(projectRoot, "package.json");
|
|
93
|
+
if (!fs.existsSync(packagePath)) return {};
|
|
94
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
95
|
+
return packageJson.scripts && typeof packageJson.scripts === "object"
|
|
96
|
+
? packageJson.scripts
|
|
97
|
+
: {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function executionScriptFor(run, scripts, configuredScript = "") {
|
|
101
|
+
const environment = String(run.environment || "").trim().toLowerCase();
|
|
102
|
+
const capturedScript = String(run.execution?.script || "").trim();
|
|
103
|
+
const preferredScript = String(capturedScript || configuredScript).trim();
|
|
104
|
+
|
|
105
|
+
if (preferredScript) {
|
|
106
|
+
if (!Object.hasOwn(scripts, preferredScript)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`El script de reejecución "${preferredScript}" ya no existe en package.json.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return preferredScript;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (run.source?.commit === "demo" && Object.hasOwn(scripts, "demo")) {
|
|
115
|
+
return "demo";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const environmentToken = new RegExp(
|
|
119
|
+
`(^|[-_:])${environment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[-_:])`,
|
|
120
|
+
"i",
|
|
121
|
+
);
|
|
122
|
+
const candidates = Object.keys(scripts).filter(
|
|
123
|
+
(name) => /(^|[-_:])report($|[-_:])/i.test(name) && environmentToken.test(name),
|
|
124
|
+
);
|
|
125
|
+
if (candidates.length === 1) return candidates[0];
|
|
126
|
+
if (candidates.length > 1) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Hay más de un script de reporte compatible. Configurá ELMULO_RERUN_SCRIPT para elegir uno.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Esta corrida no registró el script que la originó y no se encontró uno compatible.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveRerunPlan(projectRoot, run, options = {}) {
|
|
137
|
+
const runId = String(run.id || "");
|
|
138
|
+
const environment = String(run.environment || "").trim();
|
|
139
|
+
const tagExpression = String(run.tag_expression ?? run.tagExpression ?? "").trim();
|
|
140
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) {
|
|
141
|
+
throw new Error("La corrida histórica no tiene una identidad válida.");
|
|
142
|
+
}
|
|
143
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(environment)) {
|
|
144
|
+
throw new Error("La corrida histórica no tiene un ambiente reutilizable.");
|
|
145
|
+
}
|
|
146
|
+
if (/[\0\r\n]/.test(tagExpression) || tagExpression.length > 4_000) {
|
|
147
|
+
throw new Error("La expresión de tags histórica no es segura para reejecutar.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const scripts = readPackageScripts(projectRoot);
|
|
151
|
+
const script = executionScriptFor(
|
|
152
|
+
run,
|
|
153
|
+
scripts,
|
|
154
|
+
options.configuredScript || process.env.ELMULO_RERUN_SCRIPT,
|
|
155
|
+
);
|
|
156
|
+
const npmCliPath = resolveNpmCli(projectRoot, options);
|
|
157
|
+
const command = process.execPath;
|
|
158
|
+
const args = [npmCliPath, "run", script];
|
|
159
|
+
const historicalArgs = normalizeRerunArgs(run.execution?.args);
|
|
160
|
+
if (historicalArgs !== null) {
|
|
161
|
+
if (historicalArgs.length) args.push("--", ...historicalArgs);
|
|
162
|
+
} else if (tagExpression) {
|
|
163
|
+
args.push("--", tagExpression);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
command,
|
|
168
|
+
args,
|
|
169
|
+
cwd: projectRoot,
|
|
170
|
+
script,
|
|
171
|
+
rerunArgs: historicalArgs,
|
|
172
|
+
environment,
|
|
173
|
+
tagExpression,
|
|
174
|
+
env: {
|
|
175
|
+
...process.env,
|
|
176
|
+
CYPRESS_ENVIRONMENT: environment,
|
|
177
|
+
CYPRESS_TAGS: tagExpression,
|
|
178
|
+
ELMULO_RERUN_OF: runId,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function publicStatus(status) {
|
|
184
|
+
if (!status) return null;
|
|
185
|
+
return {
|
|
186
|
+
runId: status.runId,
|
|
187
|
+
status: status.status,
|
|
188
|
+
script: status.script,
|
|
189
|
+
environment: status.environment,
|
|
190
|
+
tagExpression: status.tagExpression,
|
|
191
|
+
startedAt: status.startedAt,
|
|
192
|
+
finishedAt: status.finishedAt || null,
|
|
193
|
+
exitCode: status.exitCode ?? null,
|
|
194
|
+
error: status.error || "",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createRerunManager(projectRoot, options = {}) {
|
|
199
|
+
const statuses = new Map();
|
|
200
|
+
let activeRunId = null;
|
|
201
|
+
const spawnProcess = options.spawnProcess || spawn;
|
|
202
|
+
|
|
203
|
+
function describe(run) {
|
|
204
|
+
const existing = publicStatus(statuses.get(run.id));
|
|
205
|
+
if (existing) return { available: true, ...existing };
|
|
206
|
+
try {
|
|
207
|
+
const plan = resolveRerunPlan(projectRoot, run, options);
|
|
208
|
+
return {
|
|
209
|
+
available: true,
|
|
210
|
+
runId: run.id,
|
|
211
|
+
status: "idle",
|
|
212
|
+
script: plan.script,
|
|
213
|
+
environment: plan.environment,
|
|
214
|
+
tagExpression: plan.tagExpression,
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
available: false,
|
|
219
|
+
runId: run.id,
|
|
220
|
+
status: "unavailable",
|
|
221
|
+
reason: error.message,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function start(run) {
|
|
227
|
+
const activeStatus = activeRunId ? statuses.get(activeRunId) : null;
|
|
228
|
+
if (activeStatus && !TERMINAL_STATUSES.has(activeStatus.status)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Ya se está reejecutando la corrida ${activeRunId}. Esperá a que termine.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const plan = resolveRerunPlan(projectRoot, run, options);
|
|
235
|
+
const status = {
|
|
236
|
+
runId: run.id,
|
|
237
|
+
status: "running",
|
|
238
|
+
script: plan.script,
|
|
239
|
+
environment: plan.environment,
|
|
240
|
+
tagExpression: plan.tagExpression,
|
|
241
|
+
startedAt: new Date().toISOString(),
|
|
242
|
+
finishedAt: null,
|
|
243
|
+
exitCode: null,
|
|
244
|
+
error: "",
|
|
245
|
+
};
|
|
246
|
+
statuses.set(run.id, status);
|
|
247
|
+
activeRunId = run.id;
|
|
248
|
+
|
|
249
|
+
let child;
|
|
250
|
+
try {
|
|
251
|
+
child = spawnProcess(plan.command, plan.args, {
|
|
252
|
+
cwd: plan.cwd,
|
|
253
|
+
env: plan.env,
|
|
254
|
+
shell: false,
|
|
255
|
+
stdio: "ignore",
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
status.status = "failed";
|
|
260
|
+
status.error = error.message;
|
|
261
|
+
status.finishedAt = new Date().toISOString();
|
|
262
|
+
activeRunId = null;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
child.once("error", (error) => {
|
|
267
|
+
status.status = "failed";
|
|
268
|
+
status.error = error.message;
|
|
269
|
+
status.finishedAt = new Date().toISOString();
|
|
270
|
+
if (activeRunId === run.id) activeRunId = null;
|
|
271
|
+
});
|
|
272
|
+
child.once("exit", (exitCode) => {
|
|
273
|
+
status.exitCode = exitCode;
|
|
274
|
+
status.status = exitCode === 0 ? "completed" : "failed";
|
|
275
|
+
status.error = exitCode === 0
|
|
276
|
+
? ""
|
|
277
|
+
: `El script de reporte terminó con código ${exitCode}.`;
|
|
278
|
+
status.finishedAt = new Date().toISOString();
|
|
279
|
+
if (activeRunId === run.id) activeRunId = null;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return publicStatus(status);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function get(runId) {
|
|
286
|
+
return publicStatus(statuses.get(runId));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { describe, get, start };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
createRerunManager,
|
|
294
|
+
executionScriptFor,
|
|
295
|
+
normalizeRerunArgs,
|
|
296
|
+
resolveNpmCli,
|
|
297
|
+
resolveRerunPlan,
|
|
298
|
+
};
|
package/security.cjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const DEFAULT_MAX_TEXT_LENGTH = 20_000;
|
|
2
|
+
const SECRET_PATTERN =
|
|
3
|
+
/((?:authorization|api[-_]?key|token|secret|password|passwd|cookie|set-cookie)\s*[:=]\s*)([^\s,;]+)/gi;
|
|
4
|
+
const BEARER_PATTERN = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi;
|
|
5
|
+
const CARD_PATTERN = /\b(?:\d[ -]*?){13,19}\b/g;
|
|
6
|
+
|
|
7
|
+
function redactSecrets(value) {
|
|
8
|
+
return String(value ?? "")
|
|
9
|
+
.replace(BEARER_PATTERN, "$1[REDACTADO]")
|
|
10
|
+
.replace(SECRET_PATTERN, "$1[REDACTADO]")
|
|
11
|
+
.replace(CARD_PATTERN, (candidate) => {
|
|
12
|
+
const digits = candidate.replace(/\D/g, "");
|
|
13
|
+
return digits.length >= 13 ? `[TARJETA ****${digits.slice(-4)}]` : candidate;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeText(value, maxLength = DEFAULT_MAX_TEXT_LENGTH) {
|
|
18
|
+
return redactSecrets(value)
|
|
19
|
+
.replace(/\u0000/g, "")
|
|
20
|
+
.slice(0, maxLength);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitizeLogEntry(entry = {}) {
|
|
24
|
+
return {
|
|
25
|
+
name: sanitizeText(entry.name || "command", 120),
|
|
26
|
+
message: sanitizeText(entry.message || "", 8_000),
|
|
27
|
+
timestamp: Number.isFinite(Date.parse(entry.timestamp || ""))
|
|
28
|
+
? new Date(entry.timestamp).toISOString()
|
|
29
|
+
: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeActor(value) {
|
|
34
|
+
return sanitizeText(value || "Usuario local", 120).trim() || "Usuario local";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isAllowedAttachment(mimeType) {
|
|
38
|
+
return new Set([
|
|
39
|
+
"application/json",
|
|
40
|
+
"application/pdf",
|
|
41
|
+
"image/jpeg",
|
|
42
|
+
"image/png",
|
|
43
|
+
"text/csv",
|
|
44
|
+
"text/plain",
|
|
45
|
+
"video/mp4",
|
|
46
|
+
]).has(String(mimeType || "").toLowerCase());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
DEFAULT_MAX_TEXT_LENGTH,
|
|
51
|
+
isAllowedAttachment,
|
|
52
|
+
normalizeActor,
|
|
53
|
+
redactSecrets,
|
|
54
|
+
sanitizeLogEntry,
|
|
55
|
+
sanitizeText,
|
|
56
|
+
};
|