plum-e2e 1.2.4 → 1.3.1
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/CLAUDE.md +201 -0
- package/README.md +245 -90
- package/backend/_scaffold/utils/browser.ts +5 -2
- package/backend/app.js +9 -1
- package/backend/config/scripts/generate-report.js +34 -73
- package/backend/config/scripts/run-tests.js +13 -3
- package/backend/constants/triggers.js +67 -0
- package/backend/lib/reportFilename.js +37 -0
- package/backend/lib/testChunker.js +73 -0
- package/backend/middleware/auth.js +32 -0
- package/backend/package.json +4 -2
- package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
- package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
- package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
- package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
- package/backend/prisma/schema.prisma +21 -1
- package/backend/routes/cron.routes.js +28 -0
- package/backend/routes/node.routes.js +121 -0
- package/backend/routes/reports.routes.js +23 -20
- package/backend/routes/runners.routes.js +83 -0
- package/backend/scripts/add-local-runner.js +120 -0
- package/backend/scripts/create-test.js +148 -0
- package/backend/server.js +16 -7
- package/backend/services/backupService.js +3 -30
- package/backend/services/cronService.js +220 -36
- package/backend/services/reportService.js +227 -55
- package/backend/services/runnerService.js +179 -0
- package/backend/websockets/socketHandler.js +162 -21
- package/bin/plum.js +160 -31
- package/docker-compose.node.yml +59 -0
- package/docker-compose.yml +2 -0
- package/frontend/package.json +1 -4
- package/frontend/src/app.css +20 -254
- package/frontend/src/app.html +1 -1
- package/frontend/src/lib/api/reports.js +17 -36
- package/frontend/src/lib/api/runners.js +61 -0
- package/frontend/src/lib/api/schedules.js +34 -5
- package/frontend/src/lib/api/settings.js +5 -5
- package/frontend/src/lib/api/tests.js +2 -19
- package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
- package/frontend/src/lib/components/layout/Nav.svelte +42 -47
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
- package/frontend/src/lib/components/ui/Badge.svelte +6 -1
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
- package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
- package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
- package/frontend/src/lib/constants.js +36 -0
- package/frontend/src/lib/stores/runner.js +23 -12
- package/frontend/src/lib/styles/global.css +176 -0
- package/frontend/src/lib/styles/reset.css +86 -0
- package/frontend/src/lib/styles/tokens.css +90 -0
- package/frontend/src/lib/utils/format.js +46 -0
- package/frontend/src/routes/+page.svelte +16 -35
- package/frontend/src/routes/reports/+page.svelte +84 -167
- package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +325 -76
- package/frontend/src/routes/reports/live/+page.svelte +704 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
- package/frontend/src/routes/settings/+page.svelte +774 -127
- package/frontend/static/favicon-32x32.png +0 -0
- package/frontend/static/favicon.ico +0 -0
- package/package.json +1 -1
- package/frontend/static/favicon.png +0 -0
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
const cron = require('node-cron');
|
|
19
19
|
const { spawn } = require('child_process');
|
|
20
20
|
const prisma = require('./prisma');
|
|
21
|
+
const runnerService = require('./runnerService');
|
|
22
|
+
const reportService = require('./reportService');
|
|
23
|
+
const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
24
|
+
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
25
|
+
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
21
26
|
|
|
22
27
|
const scheduledJobs = {};
|
|
23
28
|
let _io = null;
|
|
@@ -26,34 +31,159 @@ const setSocketIO = (io) => {
|
|
|
26
31
|
_io = io;
|
|
27
32
|
};
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
delete scheduledJobs[taskName];
|
|
33
|
-
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Internal helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/** Parses the stored comma-separated runnerIds string into an array. */
|
|
39
|
+
function parseRunnerIds(str) {
|
|
40
|
+
if (!str || !str.trim()) return [BUILT_IN_RUNNER_ID];
|
|
41
|
+
return str
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves display names and DB ids for a list of runner IDs. */
|
|
48
|
+
async function resolveLaneInfos(runnerIds) {
|
|
49
|
+
return Promise.all(
|
|
50
|
+
runnerIds.map(async (id) => {
|
|
51
|
+
if (id === BUILT_IN_RUNNER_ID) return { id, name: 'Built-in', dbId: null };
|
|
52
|
+
const r = await runnerService.getById(id);
|
|
53
|
+
return { id, name: r?.name ?? id, dbId: r?.id ?? null };
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
}
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Run paths
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Single built-in runner — spawns tests locally.
|
|
64
|
+
* TRIGGER is set to taskName so generate-report.js can persist it correctly.
|
|
65
|
+
*/
|
|
66
|
+
function runSingleBuiltIn({ taskName, tags, workers, browser }) {
|
|
67
|
+
const env = {
|
|
68
|
+
...process.env,
|
|
69
|
+
TAG: tags,
|
|
70
|
+
TRIGGER: taskName,
|
|
71
|
+
BROWSER: browser
|
|
72
|
+
};
|
|
73
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
74
|
+
|
|
75
|
+
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
76
|
+
task.stdout.on('data', (d) => process.stdout.write(d));
|
|
77
|
+
task.stderr.on('data', (d) => process.stderr.write(d));
|
|
78
|
+
task.on('close', (code) => {
|
|
79
|
+
console.log(`Task "${taskName}" finished with code ${code}`);
|
|
80
|
+
if (_io) _io.emit('cron-done', { taskName, code });
|
|
49
81
|
});
|
|
50
82
|
}
|
|
51
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Multi-runner distributed path — splits tests across nodes and combines reports.
|
|
86
|
+
* triggerType = taskName so the combined report is correctly attributed.
|
|
87
|
+
*/
|
|
88
|
+
async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
|
|
89
|
+
const allIds = getTestIdsForTag(tags);
|
|
90
|
+
const chunks = chunkTests(allIds, runnerIds.length);
|
|
91
|
+
const laneInfos = await resolveLaneInfos(runnerIds);
|
|
92
|
+
|
|
93
|
+
const collectedReports = new Array(runnerIds.length).fill(null);
|
|
94
|
+
let doneCount = 0;
|
|
95
|
+
let overallCode = 0;
|
|
96
|
+
|
|
97
|
+
function onLaneDone(idx, code, reportContent) {
|
|
98
|
+
if (code !== 0) overallCode = code;
|
|
99
|
+
collectedReports[idx] = reportContent;
|
|
100
|
+
doneCount++;
|
|
101
|
+
|
|
102
|
+
if (doneCount === runnerIds.length) {
|
|
103
|
+
console.log(`Task "${taskName}" — all runners done (exit ${overallCode})`);
|
|
104
|
+
if (_io) _io.emit('cron-done', { taskName, code: overallCode });
|
|
105
|
+
|
|
106
|
+
reportService
|
|
107
|
+
.saveCombinedReport({
|
|
108
|
+
reports: collectedReports,
|
|
109
|
+
runners: laneInfos,
|
|
110
|
+
overallCode,
|
|
111
|
+
tag: tags,
|
|
112
|
+
triggerType: taskName,
|
|
113
|
+
browser
|
|
114
|
+
})
|
|
115
|
+
.catch((e) => console.error(`[cron] Failed to save combined report: ${e.message}`));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < runnerIds.length; i++) {
|
|
120
|
+
const lane = laneInfos[i];
|
|
121
|
+
const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tags;
|
|
122
|
+
|
|
123
|
+
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
124
|
+
const env = {
|
|
125
|
+
...process.env,
|
|
126
|
+
TAG: chunkTag,
|
|
127
|
+
TRIGGER: TRIGGER_REMOTE, // node-mode: file naming only, not persisted to DB
|
|
128
|
+
BROWSER: browser,
|
|
129
|
+
PLUM_MODE: 'node'
|
|
130
|
+
};
|
|
131
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
132
|
+
|
|
133
|
+
const task = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
134
|
+
task.stdout.on('data', (d) => process.stdout.write(d));
|
|
135
|
+
task.stderr.on('data', (d) => process.stderr.write(d));
|
|
136
|
+
const idx = i;
|
|
137
|
+
task.on('close', (code) => onLaneDone(idx, code, readCucumberReportFile()));
|
|
138
|
+
} else {
|
|
139
|
+
const idx = i;
|
|
140
|
+
runnerService.dispatchAndPoll(
|
|
141
|
+
lane.id,
|
|
142
|
+
{ tags: chunkTag, browser, workers },
|
|
143
|
+
(log) => process.stdout.write(log),
|
|
144
|
+
(code, content) => onLaneDone(idx, code, content)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Public: job execution
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async function runCronJob(job) {
|
|
155
|
+
const { taskName, tags, workers, browser, runnerIds: runnerIdsStr } = job;
|
|
156
|
+
const runnerIds = parseRunnerIds(runnerIdsStr);
|
|
157
|
+
|
|
158
|
+
if (_io) _io.emit('cron-start', { taskName });
|
|
159
|
+
console.log(`Running scheduled task: "${taskName}" → runners: ${runnerIds.join(', ')}`);
|
|
160
|
+
|
|
161
|
+
const isSingleBuiltIn = runnerIds.length === 1 && runnerIds[0] === BUILT_IN_RUNNER_ID;
|
|
162
|
+
|
|
163
|
+
if (isSingleBuiltIn) {
|
|
164
|
+
runSingleBuiltIn({ taskName, tags, workers, browser });
|
|
165
|
+
} else {
|
|
166
|
+
await runDistributed({ taskName, tags, workers, browser, runnerIds });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Public: scheduling
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function scheduleJob(job) {
|
|
175
|
+
const { taskName, cronExpression } = job;
|
|
176
|
+
if (scheduledJobs[taskName]) {
|
|
177
|
+
scheduledJobs[taskName].stop();
|
|
178
|
+
delete scheduledJobs[taskName];
|
|
179
|
+
}
|
|
180
|
+
if (job.enabled === false) return; // disabled jobs are not scheduled
|
|
181
|
+
scheduledJobs[taskName] = cron.schedule(cronExpression, () => runCronJob(job));
|
|
182
|
+
}
|
|
183
|
+
|
|
52
184
|
const init = async () => {
|
|
53
185
|
const jobs = await prisma.cronJob.findMany();
|
|
54
|
-
for (const job of jobs)
|
|
55
|
-
scheduleJob(job.taskName, job.cronExpression, job.tags, job.workers);
|
|
56
|
-
}
|
|
186
|
+
for (const job of jobs) scheduleJob(job);
|
|
57
187
|
console.log(`⏰ Scheduled ${jobs.length} cron job(s) from database`);
|
|
58
188
|
};
|
|
59
189
|
|
|
@@ -65,53 +195,107 @@ const reload = async () => {
|
|
|
65
195
|
await init();
|
|
66
196
|
};
|
|
67
197
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Public: CRUD
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const getAllCronJobs = () => prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } });
|
|
71
203
|
|
|
72
|
-
const addCronJob = async ({ taskName, cronExpression, tags, workers }) => {
|
|
204
|
+
const addCronJob = async ({ taskName, cronExpression, tags, workers, browser, runnerIds }) => {
|
|
73
205
|
if (!cronExpression || !taskName || !tags) {
|
|
74
206
|
return { status: 400, message: 'Missing required parameters' };
|
|
75
207
|
}
|
|
208
|
+
const runnerIdsStr =
|
|
209
|
+
Array.isArray(runnerIds) && runnerIds.length > 0 ? runnerIds.join(',') : BUILT_IN_RUNNER_ID;
|
|
210
|
+
|
|
76
211
|
const job = await prisma.cronJob.create({
|
|
77
|
-
data: {
|
|
212
|
+
data: {
|
|
213
|
+
taskName,
|
|
214
|
+
cronExpression,
|
|
215
|
+
tags,
|
|
216
|
+
workers: workers ?? 1,
|
|
217
|
+
browser: browser ?? 'chromium',
|
|
218
|
+
runnerIds: runnerIdsStr,
|
|
219
|
+
runnerId: null
|
|
220
|
+
}
|
|
78
221
|
});
|
|
79
|
-
scheduleJob(job
|
|
80
|
-
return { status: 201, message: `Cron job ${taskName} added` };
|
|
222
|
+
scheduleJob(job);
|
|
223
|
+
return { status: 201, message: `Cron job "${taskName}" added` };
|
|
81
224
|
};
|
|
82
225
|
|
|
83
226
|
const removeCronJob = async (taskName) => {
|
|
84
227
|
const job = await prisma.cronJob.findUnique({ where: { taskName } });
|
|
85
|
-
if (!job) return { status: 404, message: `Cron job ${taskName} not found` };
|
|
228
|
+
if (!job) return { status: 404, message: `Cron job "${taskName}" not found` };
|
|
86
229
|
|
|
87
230
|
if (scheduledJobs[taskName]) {
|
|
88
231
|
scheduledJobs[taskName].stop();
|
|
89
232
|
delete scheduledJobs[taskName];
|
|
90
233
|
}
|
|
91
|
-
|
|
92
234
|
await prisma.cronJob.delete({ where: { taskName } });
|
|
93
|
-
return { status: 200, message: `Cron job ${taskName} deleted` };
|
|
235
|
+
return { status: 200, message: `Cron job "${taskName}" deleted` };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const updateCronJob = async (
|
|
239
|
+
oldTaskName,
|
|
240
|
+
{ taskName: newTaskName, cronExpression, tags, workers, browser, runnerIds }
|
|
241
|
+
) => {
|
|
242
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName: oldTaskName } });
|
|
243
|
+
if (!job) return { status: 404, message: `Cron job "${oldTaskName}" not found` };
|
|
244
|
+
|
|
245
|
+
if (scheduledJobs[oldTaskName]) {
|
|
246
|
+
scheduledJobs[oldTaskName].stop();
|
|
247
|
+
delete scheduledJobs[oldTaskName];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const effectiveName = newTaskName?.trim() || oldTaskName;
|
|
251
|
+
const runnerIdsStr =
|
|
252
|
+
Array.isArray(runnerIds) && runnerIds.length > 0 ? runnerIds.join(',') : BUILT_IN_RUNNER_ID;
|
|
253
|
+
|
|
254
|
+
const updated = await prisma.cronJob.update({
|
|
255
|
+
where: { taskName: oldTaskName },
|
|
256
|
+
data: {
|
|
257
|
+
taskName: effectiveName,
|
|
258
|
+
cronExpression,
|
|
259
|
+
tags,
|
|
260
|
+
workers: workers ?? 1,
|
|
261
|
+
browser: browser ?? 'chromium',
|
|
262
|
+
runnerIds: runnerIdsStr,
|
|
263
|
+
runnerId: null
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
scheduleJob(updated);
|
|
268
|
+
return { status: 200, message: 'Cron job updated' };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const runJobNow = async (taskName) => {
|
|
272
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName } });
|
|
273
|
+
if (!job) return { status: 404, message: `Cron job "${taskName}" not found` };
|
|
274
|
+
runCronJob(job);
|
|
275
|
+
return { status: 200 };
|
|
94
276
|
};
|
|
95
277
|
|
|
96
|
-
const
|
|
278
|
+
const toggleCronJob = async (taskName, enabled) => {
|
|
97
279
|
const job = await prisma.cronJob.findUnique({ where: { taskName } });
|
|
98
|
-
if (!job) return { status: 404, message: `Cron job ${taskName} not found` };
|
|
280
|
+
if (!job) return { status: 404, message: `Cron job "${taskName}" not found` };
|
|
99
281
|
|
|
100
282
|
const updated = await prisma.cronJob.update({
|
|
101
283
|
where: { taskName },
|
|
102
|
-
data: {
|
|
284
|
+
data: { enabled }
|
|
103
285
|
});
|
|
104
286
|
|
|
105
|
-
scheduleJob(updated
|
|
106
|
-
return { status: 200,
|
|
287
|
+
scheduleJob(updated); // re-schedules if enabled, stops and removes if disabled
|
|
288
|
+
return { status: 200, enabled: updated.enabled };
|
|
107
289
|
};
|
|
108
290
|
|
|
109
291
|
module.exports = {
|
|
110
292
|
init,
|
|
111
293
|
reload,
|
|
294
|
+
setSocketIO,
|
|
112
295
|
getAllCronJobs,
|
|
113
296
|
addCronJob,
|
|
114
297
|
removeCronJob,
|
|
115
298
|
updateCronJob,
|
|
116
|
-
|
|
299
|
+
runJobNow,
|
|
300
|
+
toggleCronJob
|
|
117
301
|
};
|
|
@@ -17,44 +17,56 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
20
21
|
const prisma = require('./prisma');
|
|
22
|
+
const { isScheduledTrigger, normaliseTrigger } = require('../constants/triggers');
|
|
23
|
+
const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Internal helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const report = await prisma.report.findFirst({ orderBy: { createdAt: 'desc' } });
|
|
30
|
-
return report ? report.fileName : null;
|
|
31
|
-
};
|
|
29
|
+
async function resolveCronJobId(triggerType) {
|
|
30
|
+
if (!isScheduledTrigger(triggerType)) return null;
|
|
31
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName: triggerType } });
|
|
32
|
+
return job?.id ?? null;
|
|
33
|
+
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Walks a screenshot filename out of the content JSON tree.
|
|
37
|
+
* Used when deleting reports to clean up screenshot files.
|
|
38
|
+
*/
|
|
39
|
+
function collectScreenshotFiles(content) {
|
|
40
|
+
const files = [];
|
|
41
|
+
for (const feature of content?.features ?? []) {
|
|
42
|
+
for (const scenario of feature?.scenarios ?? []) {
|
|
43
|
+
for (const step of scenario?.steps ?? []) {
|
|
44
|
+
if (step.screenshot) files.push(step.screenshot);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
create: { fileName, status, tags, triggerType: triggerType || 'undefined', runners, cronJobId },
|
|
43
|
-
update: {}
|
|
44
|
-
});
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const getReportDetail = (fileName) => {
|
|
48
|
-
const filePath = path.join(REPORTS_DIR, fileName);
|
|
49
|
-
if (!filePath.startsWith(REPORTS_DIR)) return null;
|
|
50
|
-
if (!fs.existsSync(filePath)) return null;
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
function deleteScreenshotFiles(content) {
|
|
52
|
+
for (const file of collectScreenshotFiles(content)) {
|
|
53
|
+
const p = path.join(SCREENSHOTS_DIR, file);
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
56
|
+
} catch {}
|
|
57
57
|
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Transforms raw Cucumber JSON into our stored format:
|
|
62
|
+
* - Resolves pass/fail status per step/scenario/feature
|
|
63
|
+
* - Extracts base64 screenshots to PNG files on disk
|
|
64
|
+
* - Stores only the filename in the content (not the base64 blob)
|
|
65
|
+
*
|
|
66
|
+
* Returns { features, status } where status is 'PASS' | 'FAIL'.
|
|
67
|
+
*/
|
|
68
|
+
function processCucumberJson(raw) {
|
|
69
|
+
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
58
70
|
|
|
59
71
|
const features = raw.map((feature) => {
|
|
60
72
|
const scenarios = (feature.elements || []).map((scenario) => {
|
|
@@ -64,17 +76,35 @@ const getReportDetail = (fileName) => {
|
|
|
64
76
|
.flatMap((step) => step.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []);
|
|
65
77
|
const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
|
|
66
78
|
|
|
67
|
-
const steps = visibleSteps.map((step, index) =>
|
|
68
|
-
|
|
69
|
-
name: step.name ?? '',
|
|
70
|
-
status: step.result?.status ?? 'pending',
|
|
71
|
-
duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
|
|
72
|
-
error: step.result?.error_message ?? null,
|
|
73
|
-
screenshot:
|
|
79
|
+
const steps = visibleSteps.map((step, index) => {
|
|
80
|
+
const screenshotData =
|
|
74
81
|
step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
|
|
75
82
|
(index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
|
|
76
|
-
null
|
|
77
|
-
|
|
83
|
+
null;
|
|
84
|
+
|
|
85
|
+
let screenshotFile = null;
|
|
86
|
+
if (screenshotData) {
|
|
87
|
+
screenshotFile = `${crypto.randomUUID()}.png`;
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(SCREENSHOTS_DIR, screenshotFile),
|
|
91
|
+
Buffer.from(screenshotData, 'base64')
|
|
92
|
+
);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`[report] Failed to write screenshot: ${e.message}`);
|
|
95
|
+
screenshotFile = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
keyword: step.keyword.trim(),
|
|
101
|
+
name: step.name ?? '',
|
|
102
|
+
status: step.result?.status ?? 'pending',
|
|
103
|
+
duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
|
|
104
|
+
error: step.result?.error_message ?? null,
|
|
105
|
+
screenshot: screenshotFile
|
|
106
|
+
};
|
|
107
|
+
});
|
|
78
108
|
|
|
79
109
|
const worstStatus = steps.reduce((acc, s) => {
|
|
80
110
|
const rank = { failed: 3, pending: 2, skipped: 1, passed: 0 };
|
|
@@ -91,38 +121,180 @@ const getReportDetail = (fileName) => {
|
|
|
91
121
|
};
|
|
92
122
|
});
|
|
93
123
|
|
|
94
|
-
const featureStatus = scenarios.some((s) => s.status === 'failed') ? 'failed' : 'passed';
|
|
95
|
-
|
|
96
124
|
return {
|
|
97
125
|
name: feature.name,
|
|
98
126
|
uri: feature.uri,
|
|
99
|
-
status:
|
|
127
|
+
status: scenarios.some((s) => s.status === 'failed') ? 'failed' : 'passed',
|
|
100
128
|
scenarios
|
|
101
129
|
};
|
|
102
130
|
});
|
|
103
131
|
|
|
104
|
-
|
|
132
|
+
const hasFailures = features.some((f) => f.status === 'failed');
|
|
133
|
+
return { features, status: hasFailures ? 'FAIL' : 'PASS' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Read operations
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const getAllReports = () =>
|
|
141
|
+
prisma.report.findMany({
|
|
142
|
+
orderBy: { createdAt: 'desc' },
|
|
143
|
+
select: {
|
|
144
|
+
id: true,
|
|
145
|
+
status: true,
|
|
146
|
+
tags: true,
|
|
147
|
+
triggerType: true,
|
|
148
|
+
runners: true,
|
|
149
|
+
browser: true,
|
|
150
|
+
runnerName: true,
|
|
151
|
+
createdAt: true
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const getLatestReportId = async () => {
|
|
156
|
+
const report = await prisma.report.findFirst({
|
|
157
|
+
orderBy: { createdAt: 'desc' },
|
|
158
|
+
select: { id: true }
|
|
159
|
+
});
|
|
160
|
+
return report?.id ?? null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const getReportDetail = async (id) => {
|
|
164
|
+
const report = await prisma.report.findUnique({
|
|
165
|
+
where: { id },
|
|
166
|
+
select: {
|
|
167
|
+
id: true,
|
|
168
|
+
status: true,
|
|
169
|
+
tags: true,
|
|
170
|
+
triggerType: true,
|
|
171
|
+
runners: true,
|
|
172
|
+
browser: true,
|
|
173
|
+
runnerName: true,
|
|
174
|
+
createdAt: true,
|
|
175
|
+
content: true
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
if (!report) return null;
|
|
179
|
+
const { content, ...meta } = report;
|
|
180
|
+
return { ...meta, features: content?.features ?? [] };
|
|
105
181
|
};
|
|
106
182
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Write operations
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Processes raw Cucumber JSON and persists the full report to the database.
|
|
189
|
+
*
|
|
190
|
+
* @param {{
|
|
191
|
+
* rawCucumberJson: object[],
|
|
192
|
+
* tags: string,
|
|
193
|
+
* triggerType: string,
|
|
194
|
+
* nodeCount?: number,
|
|
195
|
+
* browser?: string,
|
|
196
|
+
* runnerName?: string,
|
|
197
|
+
* runnerId?: string,
|
|
198
|
+
* }} opts
|
|
199
|
+
*/
|
|
200
|
+
const saveReport = async ({
|
|
201
|
+
rawCucumberJson,
|
|
202
|
+
tags,
|
|
203
|
+
triggerType,
|
|
204
|
+
nodeCount,
|
|
205
|
+
browser,
|
|
206
|
+
runnerName,
|
|
207
|
+
runnerId
|
|
208
|
+
}) => {
|
|
209
|
+
const normTrigger = normaliseTrigger(triggerType);
|
|
210
|
+
const { features, status } = processCucumberJson(rawCucumberJson);
|
|
211
|
+
const cronJobId = await resolveCronJobId(normTrigger);
|
|
212
|
+
|
|
213
|
+
return prisma.report.create({
|
|
214
|
+
data: {
|
|
215
|
+
status,
|
|
216
|
+
tags: (tags ?? '').replace(/^\(|\)$/g, '') || '@all-tests',
|
|
217
|
+
triggerType: normTrigger,
|
|
218
|
+
runners: nodeCount ?? 1,
|
|
219
|
+
browser: browser ?? 'chromium',
|
|
220
|
+
runnerName: runnerName ?? null,
|
|
221
|
+
runnerId: runnerId ?? null,
|
|
222
|
+
cronJobId,
|
|
223
|
+
content: { features }
|
|
224
|
+
}
|
|
225
|
+
});
|
|
111
226
|
};
|
|
112
227
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Merges Cucumber JSON from all distributed lanes, processes screenshots,
|
|
230
|
+
* and persists one combined report to the database.
|
|
231
|
+
*
|
|
232
|
+
* @param {{
|
|
233
|
+
* reports: (string|null)[],
|
|
234
|
+
* runners: { id: string, name: string, dbId: string|null }[],
|
|
235
|
+
* overallCode: number,
|
|
236
|
+
* tag: string,
|
|
237
|
+
* triggerType: string,
|
|
238
|
+
* browser: string,
|
|
239
|
+
* }} opts
|
|
240
|
+
*/
|
|
241
|
+
const saveCombinedReport = async ({ reports, runners, overallCode, tag, triggerType, browser }) => {
|
|
242
|
+
const featureMap = new Map();
|
|
243
|
+
for (const content of reports) {
|
|
244
|
+
if (!content) continue;
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(content);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
for (const feature of parsed) {
|
|
252
|
+
const key = feature.uri ?? feature.id ?? feature.name;
|
|
253
|
+
if (featureMap.has(key)) {
|
|
254
|
+
featureMap.get(key).elements.push(...(feature.elements ?? []));
|
|
255
|
+
} else {
|
|
256
|
+
featureMap.set(key, { ...feature, elements: [...(feature.elements ?? [])] });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
118
259
|
}
|
|
260
|
+
const combined = [...featureMap.values()];
|
|
261
|
+
|
|
262
|
+
return saveReport({
|
|
263
|
+
rawCucumberJson: combined,
|
|
264
|
+
tags: tag,
|
|
265
|
+
triggerType,
|
|
266
|
+
nodeCount: runners.length,
|
|
267
|
+
browser,
|
|
268
|
+
runnerName: runners.map((r) => r.name).join(', '),
|
|
269
|
+
runnerId: null
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Delete operations
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const deleteReport = async (id) => {
|
|
278
|
+
const report = await prisma.report.findUnique({ where: { id }, select: { content: true } });
|
|
279
|
+
if (report) deleteScreenshotFiles(report.content);
|
|
280
|
+
await prisma.report.delete({ where: { id } });
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const deleteReports = async (ids) => {
|
|
284
|
+
const reports = await prisma.report.findMany({
|
|
285
|
+
where: { id: { in: ids } },
|
|
286
|
+
select: { content: true }
|
|
287
|
+
});
|
|
288
|
+
for (const r of reports) deleteScreenshotFiles(r.content);
|
|
289
|
+
await prisma.report.deleteMany({ where: { id: { in: ids } } });
|
|
119
290
|
};
|
|
120
291
|
|
|
121
292
|
module.exports = {
|
|
122
293
|
getAllReports,
|
|
123
|
-
|
|
124
|
-
saveReport,
|
|
294
|
+
getLatestReportId,
|
|
125
295
|
getReportDetail,
|
|
296
|
+
saveReport,
|
|
297
|
+
saveCombinedReport,
|
|
126
298
|
deleteReport,
|
|
127
299
|
deleteReports
|
|
128
300
|
};
|