plum-e2e 2.2.0 → 2.4.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.
@@ -8,7 +8,8 @@
8
8
  "create-env": "node services/envService.js",
9
9
  "test": "node config/scripts/run-tests.js",
10
10
  "manage-runners": "node scripts/manage-runners.mjs",
11
- "create-test": "node scripts/create-test.js"
11
+ "create-test": "node scripts/create-test.js",
12
+ "mcp": "node mcp/server.js"
12
13
  },
13
14
  "keywords": [],
14
15
  "author": "",
@@ -22,8 +23,10 @@
22
23
  "typescript": "^5.9.2"
23
24
  },
24
25
  "dependencies": {
26
+ "@aws-sdk/client-s3": "^3.840.0",
25
27
  "@clack/prompts": "^1.5.1",
26
28
  "@cucumber/cucumber": "^11.2.0",
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
27
30
  "@prisma/client": "^6.19.3",
28
31
  "bcryptjs": "^2.4.3",
29
32
  "chai": "^4.3.6",
@@ -0,0 +1,11 @@
1
+ -- AlterTable Project: add S3 backup configuration fields
2
+ ALTER TABLE "Project" ADD COLUMN "backupEnabled" BOOLEAN NOT NULL DEFAULT false;
3
+ ALTER TABLE "Project" ADD COLUMN "backupCron" TEXT NOT NULL DEFAULT '0 2 * * *';
4
+ ALTER TABLE "Project" ADD COLUMN "backupS3Endpoint" TEXT NOT NULL DEFAULT '';
5
+ ALTER TABLE "Project" ADD COLUMN "backupS3Region" TEXT NOT NULL DEFAULT '';
6
+ ALTER TABLE "Project" ADD COLUMN "backupS3Bucket" TEXT NOT NULL DEFAULT '';
7
+ ALTER TABLE "Project" ADD COLUMN "backupS3AccessKey" TEXT NOT NULL DEFAULT '';
8
+ ALTER TABLE "Project" ADD COLUMN "backupS3SecretKey" TEXT NOT NULL DEFAULT '';
9
+ ALTER TABLE "Project" ADD COLUMN "backupS3Prefix" TEXT NOT NULL DEFAULT '';
10
+ ALTER TABLE "Project" ADD COLUMN "backupLastRunAt" TIMESTAMP(3);
11
+ ALTER TABLE "Project" ADD COLUMN "backupLastStatus" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Project" ADD COLUMN "mcpKey" TEXT NOT NULL DEFAULT '';
@@ -70,16 +70,27 @@ model Report {
70
70
  }
71
71
 
72
72
  model Project {
73
- id Int @id @default(autoincrement())
74
- name String @default("")
75
- logoUrl String @default("")
76
- testCasePrefix String @default("TC")
77
- testSuitePrefix String @default("TS")
78
- caseSeqNext Int @default(0)
79
- suiteSeqNext Int @default(0)
80
- discordWebhookUrl String @default("")
81
- slackWebhookUrl String @default("")
82
- notifyPublicUrl String @default("")
73
+ id Int @id @default(autoincrement())
74
+ name String @default("")
75
+ logoUrl String @default("")
76
+ testCasePrefix String @default("TC")
77
+ testSuitePrefix String @default("TS")
78
+ caseSeqNext Int @default(0)
79
+ suiteSeqNext Int @default(0)
80
+ discordWebhookUrl String @default("")
81
+ slackWebhookUrl String @default("")
82
+ notifyPublicUrl String @default("")
83
+ backupEnabled Boolean @default(false)
84
+ backupCron String @default("0 2 * * *")
85
+ backupS3Endpoint String @default("")
86
+ backupS3Region String @default("")
87
+ backupS3Bucket String @default("")
88
+ backupS3AccessKey String @default("")
89
+ backupS3SecretKey String @default("")
90
+ backupS3Prefix String @default("")
91
+ backupLastRunAt DateTime?
92
+ backupLastStatus String @default("")
93
+ mcpKey String @default("")
83
94
  }
84
95
 
85
96
  model User {
@@ -18,7 +18,10 @@
18
18
  const express = require('express');
19
19
  const router = express.Router();
20
20
  const backupService = require('../services/backupService');
21
+ const settingsService = require('../services/settingsService');
21
22
  const cronService = require('../services/cronService');
23
+ const backupCronService = require('../services/backupCronService');
24
+ const prisma = require('../services/prisma');
22
25
 
23
26
  router.get('/export', async (req, res) => {
24
27
  try {
@@ -35,11 +38,15 @@ router.get('/export', async (req, res) => {
35
38
 
36
39
  router.post('/import', async (req, res) => {
37
40
  try {
38
- const { cronJobs, reports, project } = req.body;
39
- if (!Array.isArray(cronJobs) && !Array.isArray(reports) && !project) {
40
- return res.status(400).json({ error: 'Invalid backup format' });
41
- }
42
- await backupService.importAll({ cronJobs, reports, project }, cronService);
41
+ const { cronJobs, project, users, runners, testSuites, testRuns } = req.body;
42
+ const hasData = [cronJobs, project, users, runners, testSuites, testRuns].some(
43
+ (v) => v !== undefined && v !== null
44
+ );
45
+ if (!hasData) return res.status(400).json({ error: 'Invalid backup format' });
46
+ await backupService.importAll(
47
+ { cronJobs, project, users, runners, testSuites, testRuns },
48
+ cronService
49
+ );
43
50
  res.json({ message: 'Import successful' });
44
51
  } catch (error) {
45
52
  console.error('Import failed:', error);
@@ -47,4 +54,62 @@ router.post('/import', async (req, res) => {
47
54
  }
48
55
  });
49
56
 
57
+ router.get('/config', async (req, res) => {
58
+ try {
59
+ const config = await settingsService.getBackupConfig();
60
+ res.json(config);
61
+ } catch (error) {
62
+ console.error('Failed to get backup config:', error);
63
+ res.status(500).json({ error: 'Failed to get backup configuration' });
64
+ }
65
+ });
66
+
67
+ router.post('/config', async (req, res) => {
68
+ try {
69
+ await settingsService.updateBackupConfig(req.body);
70
+ await backupCronService.reload();
71
+ const config = await settingsService.getBackupConfig();
72
+ res.json(config);
73
+ } catch (error) {
74
+ console.error('Failed to save backup config:', error);
75
+ res.status(500).json({ error: 'Failed to save backup configuration' });
76
+ }
77
+ });
78
+
79
+ router.post('/test-s3', async (req, res) => {
80
+ try {
81
+ // If no secret key provided in the request, fall back to the stored one
82
+ let config = { ...req.body };
83
+ if (!config.backupS3SecretKey) {
84
+ const stored = await prisma.project.findUnique({ where: { id: 1 } });
85
+ config.backupS3SecretKey = stored?.backupS3SecretKey ?? '';
86
+ }
87
+
88
+ const required = ['backupS3Bucket', 'backupS3AccessKey', 'backupS3SecretKey'];
89
+ const missing = required.filter((k) => !config[k]);
90
+ if (missing.length > 0) {
91
+ return res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` });
92
+ }
93
+
94
+ await backupService.testS3Connection(config);
95
+ res.json({ ok: true });
96
+ } catch (error) {
97
+ res.status(400).json({ error: error.message || 'Connection failed' });
98
+ }
99
+ });
100
+
101
+ router.post('/run-now', async (req, res) => {
102
+ try {
103
+ await backupCronService.runBackup();
104
+ const config = await settingsService.getBackupConfig();
105
+ if (config.backupLastStatus?.startsWith('error:')) {
106
+ return res.status(500).json({ error: config.backupLastStatus.replace('error:', '') });
107
+ }
108
+ res.json({ ok: true, lastRunAt: config.backupLastRunAt, lastStatus: config.backupLastStatus });
109
+ } catch (error) {
110
+ console.error('Backup run-now failed:', error);
111
+ res.status(500).json({ error: error.message || 'Backup failed' });
112
+ }
113
+ });
114
+
50
115
  module.exports = router;
@@ -103,4 +103,22 @@ router.post('/integrations', async (req, res, next) => {
103
103
  }
104
104
  });
105
105
 
106
+ router.get('/mcp', jwtAuth, async (req, res, next) => {
107
+ try {
108
+ const config = await settingsService.getMcpConfig();
109
+ res.json(config);
110
+ } catch (e) {
111
+ next(e);
112
+ }
113
+ });
114
+
115
+ router.post('/mcp/generate', jwtAuth, async (req, res, next) => {
116
+ try {
117
+ const config = await settingsService.generateMcpKey();
118
+ res.json(config);
119
+ } catch (e) {
120
+ next(e);
121
+ }
122
+ });
123
+
106
124
  module.exports = router;
@@ -0,0 +1,94 @@
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 path = require('path');
19
+ const { spawn } = require('child_process');
20
+ const { randomUUID } = require('crypto');
21
+ const express = require('express');
22
+ const router = express.Router();
23
+ const { jwtAuth } = require('../middleware/jwtAuth');
24
+ const prisma = require('../services/prisma');
25
+ const { TRIGGER_TYPE } = require('../constants/triggers');
26
+
27
+ const BACKEND_DIR = path.resolve(__dirname, '..');
28
+ const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
29
+
30
+ // In-memory job store: jobId → { status, exitCode, reportId, startedAt }
31
+ const jobs = new Map();
32
+
33
+ function pruneOldJobs() {
34
+ const cutoff = Date.now() - JOB_TTL_MS;
35
+ for (const [id, job] of jobs) {
36
+ if (job.startedAt < cutoff) jobs.delete(id);
37
+ }
38
+ }
39
+
40
+ router.post('/', jwtAuth, async (req, res, next) => {
41
+ try {
42
+ pruneOldJobs();
43
+
44
+ const { tag = '', browser = 'chromium', workers = 1, baseUrl, testRunId } = req.body;
45
+
46
+ const jobId = randomUUID();
47
+ const startedAt = Date.now();
48
+ jobs.set(jobId, { status: 'running', exitCode: null, reportId: null, startedAt });
49
+
50
+ const env = {
51
+ ...process.env,
52
+ TAG: tag,
53
+ TRIGGER: TRIGGER_TYPE.MCP,
54
+ BROWSER: browser,
55
+ REPORT_RUNNERS: String(workers)
56
+ };
57
+ if (Number(workers) > 1) env.PARALLEL = String(workers);
58
+ if (testRunId) env.TEST_RUN_ID = testRunId;
59
+ if (baseUrl) env.BASE_URL = baseUrl;
60
+
61
+ const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
62
+
63
+ proc.on('close', async (code) => {
64
+ try {
65
+ // Find the latest report created after this job started
66
+ const report = await prisma.report.findFirst({
67
+ where: { createdAt: { gte: new Date(startedAt) } },
68
+ orderBy: { createdAt: 'desc' },
69
+ select: { id: true, status: true }
70
+ });
71
+ jobs.set(jobId, {
72
+ status: code === 130 ? 'cancelled' : 'done',
73
+ exitCode: code,
74
+ reportId: report?.id ?? null,
75
+ startedAt
76
+ });
77
+ } catch {
78
+ jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
79
+ }
80
+ });
81
+
82
+ res.status(202).json({ jobId, status: 'running' });
83
+ } catch (e) {
84
+ next(e);
85
+ }
86
+ });
87
+
88
+ router.get('/:jobId', jwtAuth, (req, res) => {
89
+ const job = jobs.get(req.params.jobId);
90
+ if (!job) return res.status(404).json({ error: 'Job not found' });
91
+ res.json(job);
92
+ });
93
+
94
+ module.exports = router;
@@ -42,12 +42,27 @@ const {
42
42
  stopNode,
43
43
  findPidOnPort
44
44
  } = runnerProcess;
45
- const { generateToken, registerWithPrimary } = nodeRegister;
45
+ const { generateToken, registerWithPrimary, detectLanIp } = nodeRegister;
46
46
 
47
47
  const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
48
48
 
49
49
  const cancelled = (v) => clack.isCancel(v);
50
50
 
51
+ /**
52
+ * When the primary runs in Docker it cannot reach `localhost` on the host —
53
+ * only substitute when the user explicitly enters localhost/127.0.0.1.
54
+ */
55
+ function resolveNodeUrl(url) {
56
+ try {
57
+ const u = new URL(url);
58
+ if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
59
+ u.hostname = 'host.docker.internal';
60
+ return u.toString();
61
+ }
62
+ } catch {}
63
+ return url;
64
+ }
65
+
51
66
  async function fetchRunners() {
52
67
  const res = await fetch(`${API_URL}/runners`);
53
68
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -239,9 +254,15 @@ async function addRunner() {
239
254
  });
240
255
  if (cancelled(token)) return;
241
256
 
242
- // Dev nodes run as a bare process on the host; the dockerized primary reaches
243
- // them via host.docker.internal.
244
- const url = `http://host.docker.internal:${port}`;
257
+ const defaultUrl = `http://${detectLanIp()}:${port}`;
258
+ const urlInput = await clack.text({
259
+ message: 'URL the Plum server uses to reach this node',
260
+ placeholder: defaultUrl,
261
+ defaultValue: defaultUrl
262
+ });
263
+ if (cancelled(urlInput)) return;
264
+
265
+ const url = resolveNodeUrl(urlInput || defaultUrl);
245
266
 
246
267
  const s = clack.spinner();
247
268
  s.start(`Registering "${name}" with the primary...`);
package/backend/server.js CHANGED
@@ -41,15 +41,26 @@ const isNodeMode = process.env.PLUM_MODE === 'node';
41
41
  const port = parseInt(process.env.PORT || '3001', 10);
42
42
 
43
43
  let cronService = null;
44
+ let backupCronService = null;
44
45
  if (!isNodeMode) {
45
46
  const socketHandler = require('./websockets/socketHandler.js');
46
47
  cronService = require('./services/cronService');
48
+ backupCronService = require('./services/backupCronService');
47
49
  socketHandler(io);
48
50
  cronService.setSocketIO(io);
49
51
  }
50
52
 
51
53
  async function start() {
52
54
  if (cronService) await cronService.init();
55
+ if (backupCronService) await backupCronService.init();
56
+
57
+ if (!isNodeMode && !process.env.PLUM_MCP_KEY) {
58
+ try {
59
+ const settingsService = require('./services/settingsService');
60
+ const { mcpKey } = await settingsService.getMcpConfig();
61
+ if (mcpKey) process.env.PLUM_MCP_KEY = mcpKey;
62
+ } catch {}
63
+ }
53
64
 
54
65
  server.listen(port, async () => {
55
66
  console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
@@ -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 };