plum-e2e 1.3.7 → 2.2.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/README.md +111 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/lib/runnerProcess.js +50 -4
  6. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  7. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  8. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  9. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  10. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  11. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  12. package/backend/middleware/jwtAuth.js +33 -0
  13. package/backend/middleware/requireAdmin.js +25 -0
  14. package/backend/package.json +2 -0
  15. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  16. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  17. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  18. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  19. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  20. package/backend/prisma/schema.prisma +123 -10
  21. package/backend/routes/auth.routes.js +96 -0
  22. package/backend/routes/node.routes.js +9 -0
  23. package/backend/routes/runners.routes.js +10 -0
  24. package/backend/routes/settings.routes.js +71 -8
  25. package/backend/routes/test-cases.routes.js +80 -0
  26. package/backend/routes/test-runs.routes.js +122 -0
  27. package/backend/routes/test-suites.routes.js +92 -0
  28. package/backend/routes/users.routes.js +67 -0
  29. package/backend/scripts/create-test.js +7 -6
  30. package/backend/scripts/manage-runners.mjs +49 -8
  31. package/backend/server.js +22 -1
  32. package/backend/services/cronService.js +91 -7
  33. package/backend/services/notificationService.js +163 -0
  34. package/backend/services/reportService.js +96 -4
  35. package/backend/services/settingsService.js +46 -2
  36. package/backend/services/testCaseService.js +139 -0
  37. package/backend/services/testRunService.js +203 -0
  38. package/backend/services/testSuiteService.js +191 -0
  39. package/backend/services/userService.js +114 -0
  40. package/backend/websockets/socketHandler.js +96 -7
  41. package/bin/plum.js +105 -9
  42. package/frontend/src/lib/api/auth.js +69 -0
  43. package/frontend/src/lib/api/repository.js +256 -0
  44. package/frontend/src/lib/api/schedules.js +5 -1
  45. package/frontend/src/lib/api/settings.js +15 -0
  46. package/frontend/src/lib/api/users.js +52 -0
  47. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  48. package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
  49. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  50. package/frontend/src/lib/constants.js +2 -0
  51. package/frontend/src/lib/stores/auth.js +60 -0
  52. package/frontend/src/lib/stores/runner.js +11 -2
  53. package/frontend/src/routes/+layout.svelte +32 -4
  54. package/frontend/src/routes/+page.svelte +1 -1
  55. package/frontend/src/routes/login/+page.svelte +209 -0
  56. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  57. package/frontend/src/routes/settings/+page.svelte +677 -6
  58. package/frontend/src/routes/setup/+page.svelte +249 -0
  59. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  60. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  61. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  62. package/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ -- AlterTable Project: add test repository settings and sequence counters
2
+ ALTER TABLE "Project" ADD COLUMN "testCasePrefix" TEXT NOT NULL DEFAULT 'TC';
3
+ ALTER TABLE "Project" ADD COLUMN "testSuitePrefix" TEXT NOT NULL DEFAULT 'TS';
4
+ ALTER TABLE "Project" ADD COLUMN "caseSeqNext" INTEGER NOT NULL DEFAULT 0;
5
+ ALTER TABLE "Project" ADD COLUMN "suiteSeqNext" INTEGER NOT NULL DEFAULT 0;
6
+
7
+ -- AlterTable Report: add relation to TestCaseHistory (no schema change needed here, FK is on history side)
8
+
9
+ -- CreateTable User
10
+ CREATE TABLE "User" (
11
+ "id" TEXT NOT NULL,
12
+ "name" TEXT NOT NULL,
13
+ "email" TEXT NOT NULL,
14
+ "password" TEXT NOT NULL,
15
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+ "updatedAt" TIMESTAMP(3) NOT NULL,
17
+
18
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
19
+ );
20
+
21
+ -- CreateTable TestSuite
22
+ CREATE TABLE "TestSuite" (
23
+ "id" TEXT NOT NULL,
24
+ "displayId" TEXT NOT NULL,
25
+ "name" TEXT NOT NULL,
26
+ "description" TEXT NOT NULL DEFAULT '',
27
+ "priority" TEXT NOT NULL DEFAULT 'Medium',
28
+ "createdById" TEXT NOT NULL,
29
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
30
+ "updatedAt" TIMESTAMP(3) NOT NULL,
31
+
32
+ CONSTRAINT "TestSuite_pkey" PRIMARY KEY ("id")
33
+ );
34
+
35
+ -- CreateTable TestCase
36
+ CREATE TABLE "TestCase" (
37
+ "id" TEXT NOT NULL,
38
+ "displayId" TEXT NOT NULL,
39
+ "title" TEXT NOT NULL,
40
+ "description" TEXT NOT NULL DEFAULT '',
41
+ "priority" TEXT NOT NULL DEFAULT 'Medium',
42
+ "automatedTag" TEXT,
43
+ "isAutomated" BOOLEAN NOT NULL DEFAULT false,
44
+ "suiteId" TEXT NOT NULL,
45
+ "createdById" TEXT NOT NULL,
46
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
47
+ "updatedAt" TIMESTAMP(3) NOT NULL,
48
+
49
+ CONSTRAINT "TestCase_pkey" PRIMARY KEY ("id")
50
+ );
51
+
52
+ -- CreateTable TestStep
53
+ CREATE TABLE "TestStep" (
54
+ "id" TEXT NOT NULL,
55
+ "caseId" TEXT NOT NULL,
56
+ "action" TEXT NOT NULL,
57
+ "testData" TEXT NOT NULL DEFAULT '',
58
+ "expectedOutput" TEXT NOT NULL DEFAULT '',
59
+ "order" INTEGER NOT NULL,
60
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
61
+
62
+ CONSTRAINT "TestStep_pkey" PRIMARY KEY ("id")
63
+ );
64
+
65
+ -- CreateTable TestRun
66
+ CREATE TABLE "TestRun" (
67
+ "id" TEXT NOT NULL,
68
+ "title" TEXT NOT NULL,
69
+ "status" TEXT NOT NULL DEFAULT 'draft',
70
+ "createdById" TEXT NOT NULL,
71
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ "updatedAt" TIMESTAMP(3) NOT NULL,
73
+
74
+ CONSTRAINT "TestRun_pkey" PRIMARY KEY ("id")
75
+ );
76
+
77
+ -- CreateTable TestRunEntry
78
+ CREATE TABLE "TestRunEntry" (
79
+ "id" TEXT NOT NULL,
80
+ "runId" TEXT NOT NULL,
81
+ "caseId" TEXT NOT NULL,
82
+ "status" TEXT NOT NULL DEFAULT 'pending',
83
+ "notes" TEXT NOT NULL DEFAULT '',
84
+ "order" INTEGER NOT NULL DEFAULT 0,
85
+ "executedById" TEXT,
86
+ "executedAt" TIMESTAMP(3),
87
+
88
+ CONSTRAINT "TestRunEntry_pkey" PRIMARY KEY ("id")
89
+ );
90
+
91
+ -- CreateTable TestCaseHistory
92
+ CREATE TABLE "TestCaseHistory" (
93
+ "id" TEXT NOT NULL,
94
+ "caseId" TEXT NOT NULL,
95
+ "runId" TEXT,
96
+ "reportId" INTEGER,
97
+ "result" TEXT NOT NULL,
98
+ "source" TEXT NOT NULL DEFAULT 'manual',
99
+ "notes" TEXT NOT NULL DEFAULT '',
100
+ "executedById" TEXT,
101
+ "executedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
102
+
103
+ CONSTRAINT "TestCaseHistory_pkey" PRIMARY KEY ("id")
104
+ );
105
+
106
+ -- CreateIndex
107
+ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
108
+ CREATE UNIQUE INDEX "TestSuite_displayId_key" ON "TestSuite"("displayId");
109
+ CREATE UNIQUE INDEX "TestCase_displayId_key" ON "TestCase"("displayId");
110
+
111
+ -- AddForeignKey
112
+ ALTER TABLE "TestSuite" ADD CONSTRAINT "TestSuite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
113
+
114
+ -- AddForeignKey
115
+ ALTER TABLE "TestCase" ADD CONSTRAINT "TestCase_suiteId_fkey" FOREIGN KEY ("suiteId") REFERENCES "TestSuite"("id") ON DELETE CASCADE ON UPDATE CASCADE;
116
+ ALTER TABLE "TestCase" ADD CONSTRAINT "TestCase_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
117
+
118
+ -- AddForeignKey
119
+ ALTER TABLE "TestStep" ADD CONSTRAINT "TestStep_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "TestCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
120
+
121
+ -- AddForeignKey
122
+ ALTER TABLE "TestRun" ADD CONSTRAINT "TestRun_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
123
+
124
+ -- AddForeignKey
125
+ ALTER TABLE "TestRunEntry" ADD CONSTRAINT "TestRunEntry_runId_fkey" FOREIGN KEY ("runId") REFERENCES "TestRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
126
+ ALTER TABLE "TestRunEntry" ADD CONSTRAINT "TestRunEntry_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "TestCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
127
+ ALTER TABLE "TestRunEntry" ADD CONSTRAINT "TestRunEntry_executedById_fkey" FOREIGN KEY ("executedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
128
+
129
+ -- AddForeignKey
130
+ ALTER TABLE "TestCaseHistory" ADD CONSTRAINT "TestCaseHistory_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "TestCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
131
+ ALTER TABLE "TestCaseHistory" ADD CONSTRAINT "TestCaseHistory_runId_fkey" FOREIGN KEY ("runId") REFERENCES "TestRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
132
+ ALTER TABLE "TestCaseHistory" ADD CONSTRAINT "TestCaseHistory_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE SET NULL ON UPDATE CASCADE;
133
+ ALTER TABLE "TestCaseHistory" ADD CONSTRAINT "TestCaseHistory_executedById_fkey" FOREIGN KEY ("executedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
1
+ -- AlterTable User: add role column, promote first user to admin
2
+ ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
3
+ UPDATE "User" SET "role" = 'admin' WHERE "id" = (SELECT "id" FROM "User" ORDER BY "createdAt" ASC LIMIT 1);
@@ -0,0 +1,2 @@
1
+ -- TestCase.automatedTag removed: displayId is now used directly as the Cucumber tag identifier
2
+ ALTER TABLE "TestCase" DROP COLUMN IF EXISTS "automatedTag";
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "TestRunEntry" ADD COLUMN "assignedToId" TEXT;
2
+ ALTER TABLE "TestRunEntry" ADD CONSTRAINT "TestRunEntry_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
1
+ -- AlterTable Project: add webhook and public URL fields
2
+ ALTER TABLE "Project" ADD COLUMN "discordWebhookUrl" TEXT NOT NULL DEFAULT '';
3
+ ALTER TABLE "Project" ADD COLUMN "slackWebhookUrl" TEXT NOT NULL DEFAULT '';
4
+ ALTER TABLE "Project" ADD COLUMN "notifyPublicUrl" TEXT NOT NULL DEFAULT '';
5
+
6
+ -- AlterTable CronJob: add notification toggles
7
+ ALTER TABLE "CronJob" ADD COLUMN "notifyDiscord" BOOLEAN NOT NULL DEFAULT false;
8
+ ALTER TABLE "CronJob" ADD COLUMN "notifySlack" BOOLEAN NOT NULL DEFAULT false;
@@ -44,6 +44,8 @@ model CronJob {
44
44
  enabled Boolean @default(true)
45
45
  runnerId String?
46
46
  runnerIds String @default("built-in")
47
+ notifyDiscord Boolean @default(false)
48
+ notifySlack Boolean @default(false)
47
49
  runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
48
50
  createdAt DateTime @default(now())
49
51
  updatedAt DateTime @updatedAt
@@ -51,23 +53,134 @@ model CronJob {
51
53
  }
52
54
 
53
55
  model Report {
54
- id Int @id @default(autoincrement())
56
+ id Int @id @default(autoincrement())
55
57
  status String
56
58
  tags String
57
- triggerType String @default("manual-trigger")
58
- runners Int @default(1)
59
- browser String @default("chromium")
59
+ triggerType String @default("manual-trigger")
60
+ runners Int @default(1)
61
+ browser String @default("chromium")
60
62
  runnerName String?
61
63
  runnerId String?
62
- runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
64
+ runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
63
65
  cronJobId Int?
64
- cronJob CronJob? @relation(fields: [cronJobId], references: [id], onDelete: SetNull)
66
+ cronJob CronJob? @relation(fields: [cronJobId], references: [id], onDelete: SetNull)
65
67
  content Json
66
- createdAt DateTime @default(now())
68
+ createdAt DateTime @default(now())
69
+ testHistory TestCaseHistory[]
67
70
  }
68
71
 
69
72
  model Project {
70
- id Int @id @default(autoincrement())
71
- name String @default("")
72
- logoUrl 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
+ }
84
+
85
+ model User {
86
+ id String @id @default(cuid())
87
+ name String
88
+ email String @unique
89
+ password String
90
+ role String @default("user")
91
+ createdAt DateTime @default(now())
92
+ updatedAt DateTime @updatedAt
93
+
94
+ suites TestSuite[]
95
+ cases TestCase[]
96
+ runs TestRun[]
97
+ executedEntries TestRunEntry[] @relation("entryExecutor")
98
+ assignedEntries TestRunEntry[] @relation("entryAssignee")
99
+ caseHistories TestCaseHistory[]
100
+ }
101
+
102
+ model TestSuite {
103
+ id String @id @default(cuid())
104
+ displayId String @unique
105
+ name String
106
+ description String @default("")
107
+ priority String @default("Medium")
108
+ createdById String
109
+ createdBy User @relation(fields: [createdById], references: [id])
110
+ createdAt DateTime @default(now())
111
+ updatedAt DateTime @updatedAt
112
+ cases TestCase[]
113
+ }
114
+
115
+ model TestCase {
116
+ id String @id @default(cuid())
117
+ displayId String @unique
118
+ title String
119
+ description String @default("")
120
+ priority String @default("Medium")
121
+ isAutomated Boolean @default(false)
122
+ suiteId String
123
+ suite TestSuite @relation(fields: [suiteId], references: [id], onDelete: Cascade)
124
+ createdById String
125
+ createdBy User @relation(fields: [createdById], references: [id])
126
+ createdAt DateTime @default(now())
127
+ updatedAt DateTime @updatedAt
128
+ steps TestStep[]
129
+ runEntries TestRunEntry[]
130
+ history TestCaseHistory[]
131
+ }
132
+
133
+ model TestStep {
134
+ id String @id @default(cuid())
135
+ caseId String
136
+ case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
137
+ action String
138
+ testData String @default("")
139
+ expectedOutput String @default("")
140
+ order Int
141
+ createdAt DateTime @default(now())
142
+ }
143
+
144
+ model TestRun {
145
+ id String @id @default(cuid())
146
+ title String
147
+ status String @default("backlog")
148
+ createdById String
149
+ createdBy User @relation(fields: [createdById], references: [id])
150
+ createdAt DateTime @default(now())
151
+ updatedAt DateTime @updatedAt
152
+ entries TestRunEntry[]
153
+ history TestCaseHistory[]
154
+ }
155
+
156
+ model TestRunEntry {
157
+ id String @id @default(cuid())
158
+ runId String
159
+ run TestRun @relation(fields: [runId], references: [id], onDelete: Cascade)
160
+ caseId String
161
+ case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
162
+ status String @default("pending")
163
+ notes String @default("")
164
+ order Int @default(0)
165
+ executedById String?
166
+ executedBy User? @relation("entryExecutor", fields: [executedById], references: [id])
167
+ executedAt DateTime?
168
+ assignedToId String?
169
+ assignedTo User? @relation("entryAssignee", fields: [assignedToId], references: [id])
170
+ }
171
+
172
+ model TestCaseHistory {
173
+ id String @id @default(cuid())
174
+ caseId String
175
+ case TestCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
176
+ runId String?
177
+ run TestRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
178
+ reportId Int?
179
+ report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull)
180
+ result String
181
+ source String @default("manual")
182
+ notes String @default("")
183
+ executedById String?
184
+ executedBy User? @relation(fields: [executedById], references: [id])
185
+ executedAt DateTime @default(now())
73
186
  }
@@ -0,0 +1,96 @@
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 express = require('express');
19
+ const router = express.Router();
20
+ const userService = require('../services/userService');
21
+ const { jwtAuth } = require('../middleware/jwtAuth');
22
+
23
+ router.get('/needs-setup', async (req, res, next) => {
24
+ try {
25
+ const setup = await userService.needsSetup();
26
+ res.json({ needsSetup: setup });
27
+ } catch (e) {
28
+ next(e);
29
+ }
30
+ });
31
+
32
+ router.post('/setup', async (req, res, next) => {
33
+ try {
34
+ const setup = await userService.needsSetup();
35
+ if (!setup) return res.status(403).json({ error: 'Setup already complete' });
36
+ const { name, email, password } = req.body;
37
+ if (!name || !email || !password)
38
+ return res.status(400).json({ error: 'name, email and password are required' });
39
+ const user = await userService.createUser({ name, email, password, role: 'admin' });
40
+ res.status(201).json({ user });
41
+ } catch (e) {
42
+ next(e);
43
+ }
44
+ });
45
+
46
+ router.post('/login', async (req, res, next) => {
47
+ try {
48
+ const { email, password } = req.body;
49
+ if (!email || !password)
50
+ return res.status(400).json({ error: 'email and password are required' });
51
+ const result = await userService.login({ email, password });
52
+ if (!result) return res.status(401).json({ error: 'Invalid credentials' });
53
+ res.json(result);
54
+ } catch (e) {
55
+ next(e);
56
+ }
57
+ });
58
+
59
+ router.get('/me', jwtAuth, async (req, res, next) => {
60
+ try {
61
+ const user = await userService.getById(req.user.userId);
62
+ if (!user) return res.status(404).json({ error: 'User not found' });
63
+ res.json({ user });
64
+ } catch (e) {
65
+ next(e);
66
+ }
67
+ });
68
+
69
+ router.post('/change-password', jwtAuth, async (req, res, next) => {
70
+ try {
71
+ const { currentPassword, newPassword } = req.body;
72
+ if (!currentPassword || !newPassword)
73
+ return res.status(400).json({ error: 'currentPassword and newPassword are required' });
74
+ const result = await userService.updatePassword(req.user.userId, {
75
+ currentPassword,
76
+ newPassword
77
+ });
78
+ if (!result.ok) return res.status(400).json({ error: result.error });
79
+ res.json({ ok: true });
80
+ } catch (e) {
81
+ next(e);
82
+ }
83
+ });
84
+
85
+ router.put('/update-profile', jwtAuth, async (req, res, next) => {
86
+ try {
87
+ const { name, email } = req.body;
88
+ const result = await userService.updateProfile(req.user.userId, { name, email });
89
+ if (!result.ok) return res.status(400).json({ error: result.error });
90
+ res.json({ user: result.user });
91
+ } catch (e) {
92
+ next(e);
93
+ }
94
+ });
95
+
96
+ module.exports = router;
@@ -34,6 +34,15 @@ router.get('/ping', authGuard, (req, res) => {
34
34
  res.json({ ok: true, mode: process.env.PLUM_MODE || 'server' });
35
35
  });
36
36
 
37
+ // Graceful shutdown for node-mode processes (no-op on the primary server)
38
+ router.post('/shutdown', authGuard, (req, res) => {
39
+ if (process.env.PLUM_MODE !== 'node') {
40
+ return res.status(403).json({ error: 'Not a node runner' });
41
+ }
42
+ res.json({ ok: true });
43
+ setTimeout(() => process.exit(0), 200);
44
+ });
45
+
37
46
  // Start a remote test job
38
47
  router.post('/execute', authGuard, (req, res) => {
39
48
  const { tags, browser = 'chromium', workers = 1, tests = null } = req.body;
@@ -64,6 +64,16 @@ router.put('/:id', async (req, res) => {
64
64
 
65
65
  router.delete('/:id', async (req, res) => {
66
66
  try {
67
+ const runner = await runnerService.getById(req.params.id);
68
+ if (runner) {
69
+ try {
70
+ await fetch(`${runner.url}/api/shutdown`, {
71
+ method: 'POST',
72
+ headers: { Authorization: `Bearer ${runner.token}` },
73
+ signal: AbortSignal.timeout(3000)
74
+ });
75
+ } catch {}
76
+ }
67
77
  await runnerService.remove(req.params.id);
68
78
  res.json({ message: 'Runner deleted' });
69
79
  } catch (e) {
@@ -18,25 +18,88 @@
18
18
  const express = require('express');
19
19
  const router = express.Router();
20
20
  const settingsService = require('../services/settingsService');
21
+ const testSuiteService = require('../services/testSuiteService');
22
+ const testCaseService = require('../services/testCaseService');
23
+ const { jwtAuth } = require('../middleware/jwtAuth');
21
24
 
22
- router.get('/project', async (req, res) => {
25
+ router.get('/project', async (req, res, next) => {
23
26
  try {
24
27
  const project = await settingsService.getProject();
25
28
  res.json(project);
26
- } catch (error) {
27
- console.error('Error fetching project settings:', error);
28
- res.status(500).json({ error: 'Failed to fetch project settings' });
29
+ } catch (e) {
30
+ next(e);
29
31
  }
30
32
  });
31
33
 
32
- router.post('/project', async (req, res) => {
34
+ router.post('/project', async (req, res, next) => {
33
35
  try {
34
36
  const { name, logoUrl } = req.body;
35
37
  const project = await settingsService.updateProject({ name, logoUrl });
36
38
  res.json(project);
37
- } catch (error) {
38
- console.error('Error updating project settings:', error);
39
- res.status(500).json({ error: 'Failed to update project settings' });
39
+ } catch (e) {
40
+ next(e);
41
+ }
42
+ });
43
+
44
+ router.get('/test-prefixes', jwtAuth, async (req, res, next) => {
45
+ try {
46
+ const prefixes = await settingsService.getTestPrefixes();
47
+ res.json(prefixes);
48
+ } catch (e) {
49
+ next(e);
50
+ }
51
+ });
52
+
53
+ router.post('/test-prefixes', jwtAuth, async (req, res, next) => {
54
+ try {
55
+ const { testCasePrefix, testSuitePrefix } = req.body;
56
+ const project = await settingsService.updateTestPrefixes({ testCasePrefix, testSuitePrefix });
57
+ res.json(project);
58
+ } catch (e) {
59
+ next(e);
60
+ }
61
+ });
62
+
63
+ router.post('/test-prefixes/migrate', jwtAuth, async (req, res, next) => {
64
+ try {
65
+ const { testCasePrefix, testSuitePrefix } = req.body;
66
+ const results = {};
67
+ if (testCasePrefix) {
68
+ results.cases = await testCaseService.migratePrefix(testCasePrefix);
69
+ }
70
+ if (testSuitePrefix) {
71
+ results.suites = await testSuiteService.migratePrefix(testSuitePrefix);
72
+ }
73
+ res.json({ ok: true, ...results });
74
+ } catch (e) {
75
+ next(e);
76
+ }
77
+ });
78
+
79
+ router.get('/integrations', async (req, res, next) => {
80
+ try {
81
+ const webhooks = await settingsService.getWebhooks();
82
+ res.json(webhooks);
83
+ } catch (e) {
84
+ next(e);
85
+ }
86
+ });
87
+
88
+ router.post('/integrations', async (req, res, next) => {
89
+ try {
90
+ const { discordWebhookUrl, slackWebhookUrl, notifyPublicUrl } = req.body;
91
+ const project = await settingsService.updateWebhooks({
92
+ discordWebhookUrl,
93
+ slackWebhookUrl,
94
+ notifyPublicUrl
95
+ });
96
+ res.json({
97
+ discordWebhookUrl: project.discordWebhookUrl,
98
+ slackWebhookUrl: project.slackWebhookUrl,
99
+ notifyPublicUrl: project.notifyPublicUrl
100
+ });
101
+ } catch (e) {
102
+ next(e);
40
103
  }
41
104
  });
42
105
 
@@ -0,0 +1,80 @@
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 express = require('express');
19
+ const router = express.Router();
20
+ const { jwtAuth } = require('../middleware/jwtAuth');
21
+ const testCaseService = require('../services/testCaseService');
22
+
23
+ router.get('/:id', jwtAuth, async (req, res, next) => {
24
+ try {
25
+ const tc = await testCaseService.getById(req.params.id);
26
+ if (!tc) return res.status(404).json({ error: 'Test case not found' });
27
+ res.json({ testCase: tc });
28
+ } catch (e) {
29
+ next(e);
30
+ }
31
+ });
32
+
33
+ router.post('/', jwtAuth, async (req, res, next) => {
34
+ try {
35
+ const { suiteId, title, description, priority } = req.body;
36
+ if (!suiteId || !title)
37
+ return res.status(400).json({ error: 'suiteId and title are required' });
38
+ const testCase = await testCaseService.create({
39
+ suiteId,
40
+ title,
41
+ description,
42
+ priority,
43
+ createdById: req.user.userId
44
+ });
45
+ res.status(201).json({ testCase });
46
+ } catch (e) {
47
+ next(e);
48
+ }
49
+ });
50
+
51
+ router.put('/:id', jwtAuth, async (req, res, next) => {
52
+ try {
53
+ const { title, description, priority } = req.body;
54
+ const testCase = await testCaseService.update(req.params.id, { title, description, priority });
55
+ res.json({ testCase });
56
+ } catch (e) {
57
+ next(e);
58
+ }
59
+ });
60
+
61
+ router.put('/:id/steps', jwtAuth, async (req, res, next) => {
62
+ try {
63
+ const { steps } = req.body;
64
+ const saved = await testCaseService.upsertSteps(req.params.id, steps);
65
+ res.json({ steps: saved });
66
+ } catch (e) {
67
+ next(e);
68
+ }
69
+ });
70
+
71
+ router.delete('/:id', jwtAuth, async (req, res, next) => {
72
+ try {
73
+ await testCaseService.remove(req.params.id);
74
+ res.json({ ok: true });
75
+ } catch (e) {
76
+ next(e);
77
+ }
78
+ });
79
+
80
+ module.exports = router;