plum-e2e 1.2.4 → 1.3.0

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.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +132 -19
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +1 -1
  62. 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
- function scheduleJob(taskName, cronExpression, tags, workers) {
30
- if (scheduledJobs[taskName]) {
31
- scheduledJobs[taskName].stop();
32
- delete scheduledJobs[taskName];
33
- }
34
+ // ---------------------------------------------------------------------------
35
+ // Internal helpers
36
+ // ---------------------------------------------------------------------------
34
37
 
35
- scheduledJobs[taskName] = cron.schedule(cronExpression, () => {
36
- console.log(`Running scheduled task: ${taskName}`);
37
- if (_io) _io.emit('cron-start', { taskName });
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
- const env = { ...process.env, TAG: tags, TRIGGER: taskName };
40
- if (workers && workers > 1) env.PARALLEL = String(workers);
58
+ // ---------------------------------------------------------------------------
59
+ // Run paths
60
+ // ---------------------------------------------------------------------------
41
61
 
42
- const task = spawn('npm', ['run', 'test'], { env, shell: true });
43
- task.stdout.on('data', (data) => console.log(data.toString()));
44
- task.stderr.on('data', (data) => console.error(data.toString()));
45
- task.on('close', (code) => {
46
- console.log(`Task ${taskName} finished with code ${code}`);
47
- if (_io) _io.emit('cron-done', { taskName, code });
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
- const getAllCronJobs = async () => {
69
- return prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } });
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: { taskName, cronExpression, tags, workers: workers ?? 1 }
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.taskName, job.cronExpression, job.tags, job.workers);
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 updateCronJob = async (taskName, { cronExpression, tags, workers }) => {
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: { cronExpression, tags, workers: workers ?? 1 }
284
+ data: { enabled }
103
285
  });
104
286
 
105
- scheduleJob(updated.taskName, updated.cronExpression, updated.tags, updated.workers);
106
- return { status: 200, message: `Cron job ${taskName} updated` };
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
- setSocketIO
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
- const REPORTS_DIR = path.join(__dirname, '../reports');
25
+ // ---------------------------------------------------------------------------
26
+ // Internal helpers
27
+ // ---------------------------------------------------------------------------
23
28
 
24
- const getAllReports = async () => {
25
- return prisma.report.findMany({ orderBy: { createdAt: 'desc' } });
26
- };
27
-
28
- const getLatestReport = async () => {
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
- const saveReport = async ({ fileName, status, tags, triggerType, runners }) => {
34
- let cronJobId = null;
35
- const builtInTriggers = ['manual-trigger', 'command-line-trigger', 'undefined'];
36
- if (triggerType && !builtInTriggers.includes(triggerType)) {
37
- const job = await prisma.cronJob.findUnique({ where: { taskName: triggerType } });
38
- if (job) cronJobId = job.id;
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 prisma.report.upsert({
41
- where: { fileName },
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
- let raw;
53
- try {
54
- raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
55
- } catch {
56
- return null;
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
- keyword: step.keyword.trim(),
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: featureStatus,
127
+ status: scenarios.some((s) => s.status === 'failed') ? 'failed' : 'passed',
100
128
  scenarios
101
129
  };
102
130
  });
103
131
 
104
- return { features };
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
- const deleteReport = async (fileName) => {
108
- await prisma.report.delete({ where: { fileName } });
109
- const filePath = path.join(REPORTS_DIR, fileName);
110
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
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
- const deleteReports = async (fileNames) => {
114
- await prisma.report.deleteMany({ where: { fileName: { in: fileNames } } });
115
- for (const fileName of fileNames) {
116
- const filePath = path.join(REPORTS_DIR, fileName);
117
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
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
- getLatestReport,
124
- saveReport,
294
+ getLatestReportId,
125
295
  getReportDetail,
296
+ saveReport,
297
+ saveCombinedReport,
126
298
  deleteReport,
127
299
  deleteReports
128
300
  };