plum-e2e 2.1.0 → 2.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 (31) hide show
  1. package/README.md +61 -470
  2. package/backend/lib/runnerProcess.js +50 -4
  3. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  4. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  5. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  6. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  7. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  8. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  9. package/backend/package.json +1 -0
  10. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  11. package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
  12. package/backend/prisma/schema.prisma +22 -7
  13. package/backend/routes/backup.routes.js +70 -5
  14. package/backend/routes/node.routes.js +9 -0
  15. package/backend/routes/runners.routes.js +10 -0
  16. package/backend/routes/settings.routes.js +27 -0
  17. package/backend/scripts/manage-runners.mjs +49 -8
  18. package/backend/server.js +25 -1
  19. package/backend/services/backupCronService.js +82 -0
  20. package/backend/services/backupService.js +254 -27
  21. package/backend/services/cronService.js +91 -7
  22. package/backend/services/notificationService.js +163 -0
  23. package/backend/services/settingsService.js +74 -1
  24. package/backend/websockets/socketHandler.js +82 -6
  25. package/frontend/src/lib/api/schedules.js +5 -1
  26. package/frontend/src/lib/api/settings.js +56 -0
  27. package/frontend/src/lib/components/layout/RunnerPanel.svelte +79 -3
  28. package/frontend/src/lib/stores/runner.js +4 -2
  29. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  30. package/frontend/src/routes/settings/+page.svelte +472 -9
  31. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+
18
+ const cron = require('node-cron');
19
+ const prisma = require('./prisma');
20
+ const backupService = require('./backupService');
21
+
22
+ let scheduledJob = null;
23
+
24
+ const runBackup = async () => {
25
+ let project;
26
+ try {
27
+ project = await prisma.project.findUnique({ where: { id: 1 } });
28
+ } catch (err) {
29
+ console.error('❌ Backup: could not read project config:', err.message);
30
+ return;
31
+ }
32
+
33
+ if (!project?.backupEnabled) return;
34
+
35
+ try {
36
+ const data = await backupService.exportAll();
37
+ const key = await backupService.uploadToS3(data, project);
38
+
39
+ await prisma.project.update({
40
+ where: { id: 1 },
41
+ data: {
42
+ backupLastRunAt: new Date(),
43
+ backupLastStatus: `success:${key}`
44
+ }
45
+ });
46
+ console.log(`✅ Backup uploaded: ${key}`);
47
+ } catch (err) {
48
+ console.error('❌ Backup failed:', err.message);
49
+ try {
50
+ await prisma.project.update({
51
+ where: { id: 1 },
52
+ data: {
53
+ backupLastRunAt: new Date(),
54
+ backupLastStatus: `error:${err.message}`
55
+ }
56
+ });
57
+ } catch {}
58
+ }
59
+ };
60
+
61
+ const schedule = (cronExpr, enabled) => {
62
+ if (scheduledJob) {
63
+ scheduledJob.stop();
64
+ scheduledJob = null;
65
+ }
66
+ if (!enabled || !cronExpr || !cron.validate(cronExpr)) return;
67
+ scheduledJob = cron.schedule(cronExpr, runBackup);
68
+ console.log(`⏰ Backup scheduled: ${cronExpr}`);
69
+ };
70
+
71
+ const init = async () => {
72
+ try {
73
+ const project = await prisma.project.findUnique({ where: { id: 1 } });
74
+ schedule(project?.backupCron, project?.backupEnabled);
75
+ } catch (err) {
76
+ console.error('Failed to initialize backup cron:', err.message);
77
+ }
78
+ };
79
+
80
+ const reload = init;
81
+
82
+ module.exports = { init, reload, runBackup };
@@ -17,45 +17,272 @@
17
17
 
18
18
  const prisma = require('./prisma');
19
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Export — all data except reports (reports are too large; use pg_dump instead)
22
+ // ---------------------------------------------------------------------------
23
+
20
24
  const exportAll = async () => {
21
- const [cronJobs, project] = await Promise.all([
25
+ const [cronJobs, project, testSuites, testRuns, users, runners] = await Promise.all([
22
26
  prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
23
- prisma.project.findUnique({ where: { id: 1 } })
27
+ prisma.project.findUnique({ where: { id: 1 } }),
28
+ prisma.testSuite.findMany({
29
+ orderBy: { createdAt: 'asc' },
30
+ include: {
31
+ cases: {
32
+ include: { steps: { orderBy: { order: 'asc' } } },
33
+ orderBy: { createdAt: 'asc' }
34
+ }
35
+ }
36
+ }),
37
+ prisma.testRun.findMany({
38
+ orderBy: { createdAt: 'asc' },
39
+ include: { entries: { orderBy: { order: 'asc' } } }
40
+ }),
41
+ prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
42
+ prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
24
43
  ]);
25
44
 
26
45
  return {
27
- version: '1',
46
+ version: '2',
28
47
  exportedAt: new Date().toISOString(),
29
- cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, ...r }) => r),
30
- project: project ? { name: project.name, logoUrl: project.logoUrl } : null
48
+ disclaimer:
49
+ 'Reports are not included in this backup. Use pg_dump on the PostgreSQL volume to back up report history.',
50
+ cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
51
+ project: project
52
+ ? {
53
+ name: project.name,
54
+ logoUrl: project.logoUrl,
55
+ testCasePrefix: project.testCasePrefix,
56
+ testSuitePrefix: project.testSuitePrefix,
57
+ discordWebhookUrl: project.discordWebhookUrl,
58
+ slackWebhookUrl: project.slackWebhookUrl,
59
+ notifyPublicUrl: project.notifyPublicUrl
60
+ }
61
+ : null,
62
+ users: users.map(({ updatedAt: _, ...u }) => u),
63
+ runners: runners.map(({ createdAt: _, cronJobs: __, reports: ___, ...r }) => r),
64
+ testSuites: testSuites.map(({ cases, ...suite }) => ({
65
+ ...suite,
66
+ cases: cases.map(({ steps, runEntries: _, history: __, ...tc }) => ({
67
+ ...tc,
68
+ steps: steps.map(({ createdAt: ___, ...step }) => step)
69
+ }))
70
+ })),
71
+ testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
72
+ ...run,
73
+ entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
74
+ }))
31
75
  };
32
76
  };
33
77
 
34
- const importAll = async ({ cronJobs = [], project = null }, cronService) => {
35
- await prisma.$transaction(async (tx) => {
36
- for (const job of cronJobs) {
37
- await tx.cronJob.upsert({
38
- where: { taskName: job.taskName },
39
- create: {
40
- taskName: job.taskName,
41
- cronExpression: job.cronExpression,
42
- tags: job.tags,
43
- workers: job.workers ?? 1
44
- },
45
- update: { cronExpression: job.cronExpression, tags: job.tags, workers: job.workers ?? 1 }
46
- });
78
+ // ---------------------------------------------------------------------------
79
+ // Import upsert all exported data, preserve IDs for relational integrity
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const importAll = async (
83
+ { cronJobs = [], project = null, users = [], runners = [], testSuites = [], testRuns = [] },
84
+ cronService
85
+ ) => {
86
+ await prisma.$transaction(
87
+ async (tx) => {
88
+ // 1. Users (needed before suites/runs reference createdById)
89
+ for (const user of users) {
90
+ await tx.user.upsert({
91
+ where: { email: user.email },
92
+ create: user,
93
+ update: { name: user.name, role: user.role }
94
+ });
95
+ }
96
+
97
+ // 2. Runners
98
+ for (const runner of runners) {
99
+ await tx.runner.upsert({
100
+ where: { id: runner.id },
101
+ create: runner,
102
+ update: {
103
+ name: runner.name,
104
+ url: runner.url,
105
+ token: runner.token,
106
+ browser: runner.browser
107
+ }
108
+ });
109
+ }
110
+
111
+ // 3. CronJobs
112
+ for (const job of cronJobs) {
113
+ await tx.cronJob.upsert({
114
+ where: { taskName: job.taskName },
115
+ create: {
116
+ taskName: job.taskName,
117
+ cronExpression: job.cronExpression,
118
+ tags: job.tags,
119
+ workers: job.workers ?? 1,
120
+ browser: job.browser ?? 'chromium',
121
+ enabled: job.enabled ?? true,
122
+ runnerIds: job.runnerIds ?? 'built-in',
123
+ notifyDiscord: job.notifyDiscord ?? false,
124
+ notifySlack: job.notifySlack ?? false
125
+ },
126
+ update: {
127
+ cronExpression: job.cronExpression,
128
+ tags: job.tags,
129
+ workers: job.workers ?? 1,
130
+ browser: job.browser ?? 'chromium',
131
+ runnerIds: job.runnerIds ?? 'built-in',
132
+ notifyDiscord: job.notifyDiscord ?? false,
133
+ notifySlack: job.notifySlack ?? false
134
+ }
135
+ });
136
+ }
137
+
138
+ // 4. Project settings
139
+ if (project) {
140
+ await tx.project.upsert({
141
+ where: { id: 1 },
142
+ create: { id: 1, ...project },
143
+ update: project
144
+ });
145
+ }
146
+
147
+ // 5. Test suites + cases + steps
148
+ for (const suite of testSuites) {
149
+ const { cases = [], ...suiteData } = suite;
150
+ await tx.testSuite.upsert({
151
+ where: { displayId: suiteData.displayId },
152
+ create: suiteData,
153
+ update: {
154
+ name: suiteData.name,
155
+ description: suiteData.description,
156
+ priority: suiteData.priority
157
+ }
158
+ });
159
+
160
+ for (const tc of cases) {
161
+ const { steps = [], ...caseData } = tc;
162
+ await tx.testCase.upsert({
163
+ where: { displayId: caseData.displayId },
164
+ create: caseData,
165
+ update: {
166
+ title: caseData.title,
167
+ description: caseData.description,
168
+ priority: caseData.priority,
169
+ isAutomated: caseData.isAutomated
170
+ }
171
+ });
172
+
173
+ // Replace all steps for this case (order may have changed)
174
+ await tx.testStep.deleteMany({ where: { caseId: caseData.id } });
175
+ for (const step of steps) {
176
+ await tx.testStep.create({ data: step });
177
+ }
178
+ }
179
+ }
180
+
181
+ // 6. Test runs + entries
182
+ for (const run of testRuns) {
183
+ const { entries = [], ...runData } = run;
184
+ await tx.testRun.upsert({
185
+ where: { id: runData.id },
186
+ create: runData,
187
+ update: { title: runData.title, status: runData.status }
188
+ });
189
+
190
+ for (const entry of entries) {
191
+ await tx.testRunEntry.upsert({
192
+ where: { id: entry.id },
193
+ create: entry,
194
+ update: { status: entry.status, notes: entry.notes, order: entry.order }
195
+ });
196
+ }
197
+ }
198
+ },
199
+ { timeout: 30000 }
200
+ );
201
+
202
+ if (cronService) await cronService.reload();
203
+ };
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ const uploadToS3 = async (jsonData, config) => {
210
+ const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
211
+
212
+ const {
213
+ backupS3Endpoint,
214
+ backupS3Region,
215
+ backupS3Bucket,
216
+ backupS3AccessKey,
217
+ backupS3SecretKey,
218
+ backupS3Prefix
219
+ } = config;
220
+
221
+ const clientConfig = {
222
+ region: backupS3Region || 'auto',
223
+ credentials: {
224
+ accessKeyId: backupS3AccessKey,
225
+ secretAccessKey: backupS3SecretKey
47
226
  }
227
+ };
228
+ if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
229
+
230
+ const client = new S3Client(clientConfig);
231
+
232
+ const date = new Date().toISOString().slice(0, 10);
233
+ const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
234
+ const key = `${prefix}plum-backup-${date}.json`;
235
+
236
+ await client.send(
237
+ new PutObjectCommand({
238
+ Bucket: backupS3Bucket,
239
+ Key: key,
240
+ Body: JSON.stringify(jsonData, null, 2),
241
+ ContentType: 'application/json'
242
+ })
243
+ );
244
+
245
+ return key;
246
+ };
247
+
248
+ const testS3Connection = async (config) => {
249
+ const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
48
250
 
49
- if (project) {
50
- await tx.project.upsert({
51
- where: { id: 1 },
52
- create: { id: 1, name: project.name ?? '', logoUrl: project.logoUrl ?? '' },
53
- update: { name: project.name ?? '', logoUrl: project.logoUrl ?? '' }
54
- });
251
+ const {
252
+ backupS3Endpoint,
253
+ backupS3Region,
254
+ backupS3Bucket,
255
+ backupS3AccessKey,
256
+ backupS3SecretKey,
257
+ backupS3Prefix
258
+ } = config;
259
+
260
+ const clientConfig = {
261
+ region: backupS3Region || 'auto',
262
+ credentials: {
263
+ accessKeyId: backupS3AccessKey,
264
+ secretAccessKey: backupS3SecretKey
55
265
  }
56
- });
266
+ };
267
+ if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
57
268
 
58
- if (cronService) await cronService.reload();
269
+ const client = new S3Client(clientConfig);
270
+ const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
271
+ const key = `${prefix}.plum-connection-test`;
272
+
273
+ await client.send(
274
+ new PutObjectCommand({
275
+ Bucket: backupS3Bucket,
276
+ Key: key,
277
+ Body: 'ok',
278
+ ContentType: 'text/plain'
279
+ })
280
+ );
281
+
282
+ // Clean up the test file
283
+ try {
284
+ await client.send(new DeleteObjectCommand({ Bucket: backupS3Bucket, Key: key }));
285
+ } catch {}
59
286
  };
60
287
 
61
- module.exports = { exportAll, importAll };
288
+ module.exports = { exportAll, importAll, uploadToS3, testS3Connection };
@@ -20,6 +20,7 @@ const { spawn } = require('child_process');
20
20
  const prisma = require('./prisma');
21
21
  const runnerService = require('./runnerService');
22
22
  const reportService = require('./reportService');
23
+ const notificationService = require('./notificationService');
23
24
  const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
24
25
  const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
25
26
  const { readCucumberReportFile } = require('../lib/reportFilename');
@@ -63,7 +64,7 @@ async function resolveLaneInfos(runnerIds) {
63
64
  * Single built-in runner — spawns tests locally.
64
65
  * TRIGGER is set to taskName so generate-report.js can persist it correctly.
65
66
  */
66
- function runSingleBuiltIn({ taskName, tags, workers, browser }) {
67
+ function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack }) {
67
68
  const env = {
68
69
  ...process.env,
69
70
  TAG: tags,
@@ -78,6 +79,29 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
78
79
  task.on('close', (code) => {
79
80
  console.log(`Task "${taskName}" finished with code ${code}`);
80
81
  if (_io) _io.emit('cron-done', { taskName, code });
82
+
83
+ if (notifyDiscord || notifySlack) {
84
+ prisma.report
85
+ .findFirst({
86
+ where: { triggerType: taskName },
87
+ orderBy: { createdAt: 'desc' },
88
+ select: { id: true, status: true, content: true }
89
+ })
90
+ .then((report) => {
91
+ if (!report) return;
92
+ return notificationService.send({
93
+ jobName: taskName,
94
+ status: report.status,
95
+ content: report.content,
96
+ browser,
97
+ tags,
98
+ reportId: report.id,
99
+ notifyDiscord,
100
+ notifySlack
101
+ });
102
+ })
103
+ .catch((e) => console.error(`[cron] Notification failed: ${e.message}`));
104
+ }
81
105
  });
82
106
  }
83
107
 
@@ -85,7 +109,15 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
85
109
  * Multi-runner distributed path — splits tests across nodes and combines reports.
86
110
  * triggerType = taskName so the combined report is correctly attributed.
87
111
  */
88
- async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
112
+ async function runDistributed({
113
+ taskName,
114
+ tags,
115
+ workers,
116
+ browser,
117
+ runnerIds,
118
+ notifyDiscord,
119
+ notifySlack
120
+ }) {
89
121
  const allIds = getTestIdsForTag(tags);
90
122
  const chunks = chunkTests(allIds, runnerIds.length);
91
123
 
@@ -123,6 +155,20 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
123
155
  triggerType: taskName,
124
156
  browser
125
157
  })
158
+ .then((saved) => {
159
+ if (saved && (notifyDiscord || notifySlack)) {
160
+ return notificationService.send({
161
+ jobName: taskName,
162
+ status: saved.status,
163
+ content: saved.content,
164
+ browser,
165
+ tags,
166
+ reportId: saved.id,
167
+ notifyDiscord,
168
+ notifySlack
169
+ });
170
+ }
171
+ })
126
172
  .catch((e) => console.error(`[cron] Failed to save combined report: ${e.message}`));
127
173
  }
128
174
  }
@@ -163,7 +209,15 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
163
209
  // ---------------------------------------------------------------------------
164
210
 
165
211
  async function runCronJob(job) {
166
- const { taskName, tags, workers, browser, runnerIds: runnerIdsStr } = job;
212
+ const {
213
+ taskName,
214
+ tags,
215
+ workers,
216
+ browser,
217
+ runnerIds: runnerIdsStr,
218
+ notifyDiscord,
219
+ notifySlack
220
+ } = job;
167
221
  const runnerIds = parseRunnerIds(runnerIdsStr);
168
222
 
169
223
  if (_io) _io.emit('cron-start', { taskName });
@@ -172,9 +226,17 @@ async function runCronJob(job) {
172
226
  const isSingleBuiltIn = runnerIds.length === 1 && runnerIds[0] === BUILT_IN_RUNNER_ID;
173
227
 
174
228
  if (isSingleBuiltIn) {
175
- runSingleBuiltIn({ taskName, tags, workers, browser });
229
+ runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack });
176
230
  } else {
177
- await runDistributed({ taskName, tags, workers, browser, runnerIds });
231
+ await runDistributed({
232
+ taskName,
233
+ tags,
234
+ workers,
235
+ browser,
236
+ runnerIds,
237
+ notifyDiscord,
238
+ notifySlack
239
+ });
178
240
  }
179
241
  }
180
242
 
@@ -212,7 +274,16 @@ const reload = async () => {
212
274
 
213
275
  const getAllCronJobs = () => prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } });
214
276
 
215
- const addCronJob = async ({ taskName, cronExpression, tags, workers, browser, runnerIds }) => {
277
+ const addCronJob = async ({
278
+ taskName,
279
+ cronExpression,
280
+ tags,
281
+ workers,
282
+ browser,
283
+ runnerIds,
284
+ notifyDiscord,
285
+ notifySlack
286
+ }) => {
216
287
  if (!cronExpression || !taskName || !tags) {
217
288
  return { status: 400, message: 'Missing required parameters' };
218
289
  }
@@ -227,6 +298,8 @@ const addCronJob = async ({ taskName, cronExpression, tags, workers, browser, ru
227
298
  workers: workers ?? 1,
228
299
  browser: browser ?? 'chromium',
229
300
  runnerIds: runnerIdsStr,
301
+ notifyDiscord: notifyDiscord ?? false,
302
+ notifySlack: notifySlack ?? false,
230
303
  runnerId: null
231
304
  }
232
305
  });
@@ -248,7 +321,16 @@ const removeCronJob = async (taskName) => {
248
321
 
249
322
  const updateCronJob = async (
250
323
  oldTaskName,
251
- { taskName: newTaskName, cronExpression, tags, workers, browser, runnerIds }
324
+ {
325
+ taskName: newTaskName,
326
+ cronExpression,
327
+ tags,
328
+ workers,
329
+ browser,
330
+ runnerIds,
331
+ notifyDiscord,
332
+ notifySlack
333
+ }
252
334
  ) => {
253
335
  const job = await prisma.cronJob.findUnique({ where: { taskName: oldTaskName } });
254
336
  if (!job) return { status: 404, message: `Cron job "${oldTaskName}" not found` };
@@ -271,6 +353,8 @@ const updateCronJob = async (
271
353
  workers: workers ?? 1,
272
354
  browser: browser ?? 'chromium',
273
355
  runnerIds: runnerIdsStr,
356
+ notifyDiscord: notifyDiscord ?? false,
357
+ notifySlack: notifySlack ?? false,
274
358
  runnerId: null
275
359
  }
276
360
  });