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,191 @@
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 prisma = require('./prisma');
19
+
20
+ const suiteSelect = {
21
+ id: true,
22
+ displayId: true,
23
+ name: true,
24
+ description: true,
25
+ priority: true,
26
+ createdAt: true,
27
+ updatedAt: true,
28
+ createdBy: { select: { id: true, name: true } },
29
+ _count: { select: { cases: true } }
30
+ };
31
+
32
+ function suiteOrderBy(sortBy, sortOrder) {
33
+ const dir = sortOrder === 'desc' ? 'desc' : 'asc';
34
+ if (sortBy === 'displayId') return { displayId: dir };
35
+ if (sortBy === 'name') return { name: dir };
36
+ return { createdAt: dir };
37
+ }
38
+
39
+ async function getAll({ page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = {}) {
40
+ const skip = (page - 1) * limit;
41
+ const orderBy = suiteOrderBy(sortBy, sortOrder);
42
+ const [suites, total] = await Promise.all([
43
+ prisma.testSuite.findMany({ select: suiteSelect, orderBy, skip, take: limit }),
44
+ prisma.testSuite.count()
45
+ ]);
46
+ return { suites, total };
47
+ }
48
+
49
+ async function search(q) {
50
+ const [suites, cases] = await Promise.all([
51
+ prisma.testSuite.findMany({
52
+ where: {
53
+ OR: [
54
+ { displayId: { contains: q, mode: 'insensitive' } },
55
+ { name: { contains: q, mode: 'insensitive' } }
56
+ ]
57
+ },
58
+ select: suiteSelect,
59
+ orderBy: { createdAt: 'asc' }
60
+ }),
61
+ prisma.testCase.findMany({
62
+ where: {
63
+ OR: [
64
+ { displayId: { contains: q, mode: 'insensitive' } },
65
+ { title: { contains: q, mode: 'insensitive' } }
66
+ ]
67
+ },
68
+ select: {
69
+ id: true,
70
+ displayId: true,
71
+ title: true,
72
+ priority: true,
73
+ isAutomated: true,
74
+ suite: { select: { id: true, displayId: true, name: true } }
75
+ },
76
+ orderBy: { createdAt: 'asc' }
77
+ })
78
+ ]);
79
+ return { suites, cases };
80
+ }
81
+
82
+ async function getAllWithCases() {
83
+ return prisma.testSuite.findMany({
84
+ select: {
85
+ ...suiteSelect,
86
+ cases: {
87
+ select: {
88
+ id: true,
89
+ displayId: true,
90
+ title: true,
91
+ priority: true,
92
+ isAutomated: true
93
+ },
94
+ orderBy: { createdAt: 'asc' }
95
+ }
96
+ },
97
+ orderBy: { createdAt: 'asc' }
98
+ });
99
+ }
100
+
101
+ async function getById(id) {
102
+ return prisma.testSuite.findUnique({
103
+ where: { id },
104
+ select: {
105
+ ...suiteSelect,
106
+ cases: {
107
+ select: {
108
+ id: true,
109
+ displayId: true,
110
+ title: true,
111
+ priority: true,
112
+ isAutomated: true,
113
+ createdAt: true,
114
+ createdBy: { select: { id: true, name: true } },
115
+ _count: { select: { steps: true } }
116
+ },
117
+ orderBy: { createdAt: 'asc' }
118
+ }
119
+ }
120
+ });
121
+ }
122
+
123
+ async function create({ name, description, priority, createdById }) {
124
+ const project = await prisma.project.upsert({
125
+ where: { id: 1 },
126
+ create: { id: 1, suiteSeqNext: 1 },
127
+ update: { suiteSeqNext: { increment: 1 } },
128
+ select: { suiteSeqNext: true, testSuitePrefix: true }
129
+ });
130
+ const num = String(project.suiteSeqNext).padStart(3, '0');
131
+ const displayId = `${project.testSuitePrefix}-${num}`;
132
+
133
+ return prisma.testSuite.create({
134
+ data: {
135
+ displayId,
136
+ name,
137
+ description: description ?? '',
138
+ priority: priority ?? 'Medium',
139
+ createdById
140
+ },
141
+ select: suiteSelect
142
+ });
143
+ }
144
+
145
+ async function update(id, { name, description, priority }) {
146
+ return prisma.testSuite.update({
147
+ where: { id },
148
+ data: {
149
+ ...(name !== undefined && { name }),
150
+ ...(description !== undefined && { description }),
151
+ ...(priority !== undefined && { priority })
152
+ },
153
+ select: suiteSelect
154
+ });
155
+ }
156
+
157
+ async function remove(id) {
158
+ return prisma.testSuite.delete({ where: { id } });
159
+ }
160
+
161
+ async function migratePrefix(newPrefix) {
162
+ const suites = await prisma.testSuite.findMany({
163
+ select: { id: true, displayId: true },
164
+ orderBy: { createdAt: 'asc' }
165
+ });
166
+ const project = await prisma.project.upsert({
167
+ where: { id: 1 },
168
+ create: { id: 1 },
169
+ update: { testSuitePrefix: newPrefix },
170
+ select: { testSuitePrefix: true }
171
+ });
172
+ for (let i = 0; i < suites.length; i++) {
173
+ const num = String(i + 1).padStart(3, '0');
174
+ await prisma.testSuite.update({
175
+ where: { id: suites[i].id },
176
+ data: { displayId: `${newPrefix}-${num}` }
177
+ });
178
+ }
179
+ return project;
180
+ }
181
+
182
+ module.exports = {
183
+ getAll,
184
+ search,
185
+ getAllWithCases,
186
+ getById,
187
+ create,
188
+ update,
189
+ remove,
190
+ migratePrefix
191
+ };
@@ -0,0 +1,114 @@
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 bcrypt = require('bcryptjs');
19
+ const jwt = require('jsonwebtoken');
20
+ const prisma = require('./prisma');
21
+
22
+ const JWT_SECRET = process.env.JWT_SECRET || 'plum-dev-secret-change-in-production';
23
+ const JWT_EXPIRY = '7d';
24
+ const SALT_ROUNDS = 10;
25
+
26
+ const userSelect = { id: true, name: true, email: true, role: true, createdAt: true };
27
+
28
+ async function needsSetup() {
29
+ const count = await prisma.user.count();
30
+ return count === 0;
31
+ }
32
+
33
+ async function createUser({ name, email, password, role = 'user' }) {
34
+ const hashed = await bcrypt.hash(password, SALT_ROUNDS);
35
+ return prisma.user.create({
36
+ data: { name, email, password: hashed, role },
37
+ select: userSelect
38
+ });
39
+ }
40
+
41
+ async function login({ email, password }) {
42
+ const user = await prisma.user.findUnique({ where: { email } });
43
+ if (!user) return null;
44
+ const match = await bcrypt.compare(password, user.password);
45
+ if (!match) return null;
46
+ const token = jwt.sign(
47
+ { userId: user.id, email: user.email, name: user.name, role: user.role },
48
+ JWT_SECRET,
49
+ { expiresIn: JWT_EXPIRY }
50
+ );
51
+ return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
52
+ }
53
+
54
+ function verifyToken(token) {
55
+ return jwt.verify(token, JWT_SECRET);
56
+ }
57
+
58
+ async function getAll() {
59
+ return prisma.user.findMany({ select: userSelect, orderBy: { createdAt: 'asc' } });
60
+ }
61
+
62
+ async function getMembers() {
63
+ return prisma.user.findMany({
64
+ select: { id: true, name: true },
65
+ orderBy: { name: 'asc' }
66
+ });
67
+ }
68
+
69
+ async function getById(id) {
70
+ return prisma.user.findUnique({ where: { id }, select: userSelect });
71
+ }
72
+
73
+ async function updateProfile(id, { name, email }) {
74
+ if (email) {
75
+ const conflict = await prisma.user.findFirst({ where: { email, NOT: { id } } });
76
+ if (conflict) return { ok: false, error: 'Email already in use' };
77
+ }
78
+ const user = await prisma.user.update({
79
+ where: { id },
80
+ data: {
81
+ ...(name !== undefined && { name }),
82
+ ...(email !== undefined && { email })
83
+ },
84
+ select: userSelect
85
+ });
86
+ return { ok: true, user };
87
+ }
88
+
89
+ async function updatePassword(id, { currentPassword, newPassword }) {
90
+ const user = await prisma.user.findUnique({ where: { id } });
91
+ if (!user) return { ok: false, error: 'User not found' };
92
+ const match = await bcrypt.compare(currentPassword, user.password);
93
+ if (!match) return { ok: false, error: 'Current password is incorrect' };
94
+ const hashed = await bcrypt.hash(newPassword, SALT_ROUNDS);
95
+ await prisma.user.update({ where: { id }, data: { password: hashed } });
96
+ return { ok: true };
97
+ }
98
+
99
+ async function deleteUser(id) {
100
+ return prisma.user.delete({ where: { id } });
101
+ }
102
+
103
+ module.exports = {
104
+ needsSetup,
105
+ createUser,
106
+ login,
107
+ verifyToken,
108
+ getAll,
109
+ getMembers,
110
+ getById,
111
+ updateProfile,
112
+ updatePassword,
113
+ deleteUser
114
+ };
@@ -18,9 +18,11 @@
18
18
  const { spawn } = require('child_process');
19
19
  const runnerService = require('../services/runnerService');
20
20
  const reportService = require('../services/reportService');
21
+ const notificationService = require('../services/notificationService');
21
22
  const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
22
23
  const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
23
24
  const { readCucumberReportFile } = require('../lib/reportFilename');
25
+ const prisma = require('../services/prisma');
24
26
 
25
27
  const socketHandler = (io) => {
26
28
  io.on('connection', (socket) => {
@@ -30,12 +32,15 @@ const socketHandler = (io) => {
30
32
  const activeProcs = new Set();
31
33
 
32
34
  socket.on('run-test', async (payload, legacyWorkers) => {
33
- let tag, workers, browser, runners;
35
+ let tag, workers, browser, runners, testRunId, notifyDiscord, notifySlack;
34
36
  if (typeof payload === 'string') {
35
37
  tag = payload;
36
38
  workers = Number(legacyWorkers) > 1 ? Number(legacyWorkers) : 1;
37
39
  browser = 'chromium';
38
40
  runners = [BUILT_IN_RUNNER_ID];
41
+ testRunId = null;
42
+ notifyDiscord = false;
43
+ notifySlack = false;
39
44
  } else {
40
45
  tag = payload.tag ?? '';
41
46
  workers = Number(payload.workers) > 1 ? Number(payload.workers) : 1;
@@ -44,6 +49,9 @@ const socketHandler = (io) => {
44
49
  Array.isArray(payload.runners) && payload.runners.length > 0
45
50
  ? payload.runners
46
51
  : [BUILT_IN_RUNNER_ID];
52
+ testRunId = payload.testRunId ?? null;
53
+ notifyDiscord = payload.notifyDiscord === true;
54
+ notifySlack = payload.notifySlack === true;
47
55
  }
48
56
 
49
57
  // Drop runner ids that no longer exist (e.g. a deleted runner still
@@ -61,9 +69,30 @@ const socketHandler = (io) => {
61
69
  const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
62
70
 
63
71
  if (isSingleBuiltIn) {
64
- runBuiltIn(io, socket, activeProcs, tag, workers, browser);
72
+ runBuiltIn(
73
+ io,
74
+ socket,
75
+ activeProcs,
76
+ tag,
77
+ workers,
78
+ browser,
79
+ testRunId,
80
+ notifyDiscord,
81
+ notifySlack
82
+ );
65
83
  } else {
66
- runDistributed(io, socket, activeProcs, tag, workers, browser, runners);
84
+ runDistributed(
85
+ io,
86
+ socket,
87
+ activeProcs,
88
+ tag,
89
+ workers,
90
+ browser,
91
+ runners,
92
+ testRunId,
93
+ notifyDiscord,
94
+ notifySlack
95
+ );
67
96
  }
68
97
  });
69
98
 
@@ -85,7 +114,17 @@ const socketHandler = (io) => {
85
114
  // Single built-in runner
86
115
  // ---------------------------------------------------------------------------
87
116
 
88
- function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
117
+ function runBuiltIn(
118
+ io,
119
+ socket,
120
+ activeProcs,
121
+ tag,
122
+ workers,
123
+ browser,
124
+ testRunId,
125
+ notifyDiscord,
126
+ notifySlack
127
+ ) {
89
128
  const env = {
90
129
  ...process.env,
91
130
  TAG: tag,
@@ -94,6 +133,7 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
94
133
  BROWSER: browser
95
134
  };
96
135
  if (workers > 1) env.PARALLEL = String(workers);
136
+ if (testRunId) env.TEST_RUN_ID = testRunId;
97
137
 
98
138
  const proc = spawn('npm', ['run', 'test'], { env, shell: true });
99
139
  activeProcs.add(proc);
@@ -105,8 +145,30 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
105
145
  activeProcs.delete(proc);
106
146
  socket.emit('log', `\nTest finished with code ${code}`);
107
147
  socket.emit('done', code);
108
- // Notify all connected clients that a new report is available
109
148
  io.emit('report-ready');
149
+
150
+ if (notifyDiscord || notifySlack) {
151
+ prisma.report
152
+ .findFirst({
153
+ where: { triggerType: TRIGGER_TYPE.MANUAL },
154
+ orderBy: { createdAt: 'desc' },
155
+ select: { id: true, status: true, content: true }
156
+ })
157
+ .then((report) => {
158
+ if (!report) return;
159
+ return notificationService.send({
160
+ jobName: 'Manual Run',
161
+ status: report.status,
162
+ content: report.content,
163
+ browser,
164
+ tags: tag,
165
+ reportId: report.id,
166
+ notifyDiscord,
167
+ notifySlack
168
+ });
169
+ })
170
+ .catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
171
+ }
110
172
  });
111
173
  }
112
174
 
@@ -114,7 +176,18 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
114
176
  // Distributed (multi-runner) path
115
177
  // ---------------------------------------------------------------------------
116
178
 
117
- async function runDistributed(io, socket, activeProcs, tag, workers, browser, runnerIds) {
179
+ async function runDistributed(
180
+ io,
181
+ socket,
182
+ activeProcs,
183
+ tag,
184
+ workers,
185
+ browser,
186
+ runnerIds,
187
+ testRunId,
188
+ notifyDiscord,
189
+ notifySlack
190
+ ) {
118
191
  const allIds = getTestIdsForTag(tag);
119
192
  const chunks = chunkTests(allIds, runnerIds.length);
120
193
 
@@ -170,7 +243,8 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
170
243
  overallCode,
171
244
  tag,
172
245
  triggerType: TRIGGER_TYPE.MANUAL,
173
- browser
246
+ browser,
247
+ testRunId: testRunId ?? null
174
248
  })
175
249
  .then((saved) => {
176
250
  // Result is authoritative from the merged report, not the exit code —
@@ -178,6 +252,21 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
178
252
  // a passing run to "fail" in the live UI.
179
253
  socket.emit('done', { code: saved.status === 'PASS' ? 0 : 1, reportId: saved.id });
180
254
  io.emit('report-ready');
255
+
256
+ if (notifyDiscord || notifySlack) {
257
+ notificationService
258
+ .send({
259
+ jobName: 'Manual Run',
260
+ status: saved.status,
261
+ content: saved.content,
262
+ browser,
263
+ tags: tag,
264
+ reportId: saved.id,
265
+ notifyDiscord,
266
+ notifySlack
267
+ })
268
+ .catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
269
+ }
181
270
  })
182
271
  .catch((e) => {
183
272
  console.error('[runner] Failed to save combined report:', e.message);
package/bin/plum.js CHANGED
@@ -279,8 +279,75 @@ async function serverStart() {
279
279
  applyServerConfig(cfg);
280
280
  clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
281
281
  clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
282
- execSync('docker compose up --build', { cwd: plumRoot, stdio: 'inherit' });
283
- clack.outro(pc.dim('Plum server stopped.'));
282
+
283
+ execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
284
+
285
+ const apiBase = `http://localhost:${cfg.backendPort}`;
286
+ const s = clack.spinner();
287
+ s.start('Waiting for server to be ready…');
288
+ let ready = false;
289
+ for (let i = 0; i < 40; i++) {
290
+ await new Promise((r) => setTimeout(r, 1500));
291
+ try {
292
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
293
+ if (res.ok) {
294
+ ready = true;
295
+ break;
296
+ }
297
+ } catch {}
298
+ }
299
+ s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
300
+
301
+ if (ready) {
302
+ let needsSetup = false;
303
+ try {
304
+ const res = await fetch(`${apiBase}/auth/needs-setup`);
305
+ const data = await res.json();
306
+ needsSetup = data.needsSetup;
307
+ } catch {}
308
+
309
+ if (needsSetup) {
310
+ clack.log.info('No users found — create your first account to get started.');
311
+
312
+ const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
313
+ if (clack.isCancel(name)) {
314
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
315
+ } else {
316
+ const email = await clack.text({
317
+ message: 'Email address',
318
+ placeholder: 'jane@example.com'
319
+ });
320
+ if (clack.isCancel(email)) {
321
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
322
+ } else {
323
+ const password = await clack.password({ message: 'Password (min 8 characters)' });
324
+ if (clack.isCancel(password)) {
325
+ clack.log.warn('Skipped. Create a user at /setup in the UI.');
326
+ } else {
327
+ try {
328
+ const res = await fetch(`${apiBase}/auth/setup`, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({ name, email, password })
332
+ });
333
+ if (res.ok) {
334
+ clack.log.success(`Account created for ${email}. You can now log in.`);
335
+ } else {
336
+ const err = await res.json();
337
+ clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
338
+ }
339
+ } catch (e) {
340
+ clack.log.error(`Failed to create account: ${e.message}`);
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ clack.log.info(pc.dim('Streaming logs (Ctrl+C to detach — server keeps running):'));
349
+ execSync('docker compose logs -f', { cwd: plumRoot, stdio: 'inherit' });
350
+ clack.outro(pc.dim('Detached from logs. Run "docker compose down" to stop.'));
284
351
  }
285
352
 
286
353
  async function serverReconfig() {
@@ -588,7 +655,7 @@ switch (command) {
588
655
  const readmeContent = [
589
656
  '# My Tests',
590
657
  '',
591
- 'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber.',
658
+ 'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber + Test Repository.',
592
659
  '',
593
660
  '## Getting Started',
594
661
  '',
@@ -599,15 +666,16 @@ switch (command) {
599
666
  ' ```bash',
600
667
  ' plum run-test',
601
668
  ' ```',
602
- '3. **Write your first test** — edit a file in `tests/features/` or generate a step:',
669
+ '3. **Write your first test** — scaffold a full feature or generate a single step:',
603
670
  ' ```bash',
604
- ' plum create-step',
671
+ ' plum create-test # scaffold .feature + Page.ts + Steps.ts',
672
+ ' plum create-step # add a single step to an existing file',
605
673
  ' ```',
606
- '4. **Start the full UI** (requires Docker) to trigger tests and view reports in the browser:',
674
+ '4. **Start the full UI** (requires Docker) to trigger tests, view reports, and manage your test repository:',
607
675
  ' ```bash',
608
676
  ' plum start',
609
677
  ' ```',
610
- ' Then open **http://localhost:5173**.',
678
+ ' On first run, Plum asks you to create an admin account. Then open **http://localhost:5173** and sign in.',
611
679
  '',
612
680
  '---',
613
681
  '',
@@ -667,6 +735,19 @@ switch (command) {
667
735
  '',
668
736
  '---',
669
737
  '',
738
+ '## Test Repository',
739
+ '',
740
+ 'Plum includes a built-in test case management system. Access it from the **Test Repository** tab in the UI.',
741
+ '',
742
+ '- **Test Suites** — Group related test cases. Each suite gets an auto-assigned ID (e.g. `TS-001`).',
743
+ '- **Test Cases** — Document steps (Action / Test Data / Expected Output), set priority, and assign a Cucumber `@tag` to link automation.',
744
+ '- **Test Runs** — Build a run from any combination of cases, execute them one by one (pass/fail/blocked/skip), and track history.',
745
+ '- **Auto-linking** — When a build completes, Plum matches Cucumber scenario tags against `automatedTag` values on your test cases and marks them as automated.',
746
+ '',
747
+ 'To link a test case to automation, set its **Automated tag** (e.g. `test-login-1`) to match the `@tag` on the Cucumber scenario.',
748
+ '',
749
+ '---',
750
+ '',
670
751
  '## Cucumber & Gherkin Resources',
671
752
  '',
672
753
  'New to Cucumber? These links will get you up to speed quickly:',
@@ -700,8 +781,9 @@ switch (command) {
700
781
  `${pc.bold('Start the full UI')} ${pc.dim('(requires Docker)')}`,
701
782
  ` ${pc.cyan('plum server start')}`,
702
783
  '',
703
- `${pc.bold('Generate a step definition')}`,
704
- ` ${pc.cyan('plum create-step')}`
784
+ `${pc.bold('Generate tests')}`,
785
+ ` ${pc.cyan('plum create-test')} scaffold a new feature`,
786
+ ` ${pc.cyan('plum create-step')} add a step definition`
705
787
  ].join('\n'),
706
788
  'Next steps'
707
789
  );
@@ -885,6 +967,19 @@ switch (command) {
885
967
  break;
886
968
  }
887
969
 
970
+ case 'create-test': {
971
+ const createTestScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-test.mjs');
972
+ execSync(`node "${createTestScript}"`, {
973
+ cwd: process.cwd(),
974
+ stdio: 'inherit',
975
+ env: {
976
+ ...process.env,
977
+ TESTS_ROOT: userTestsPath
978
+ }
979
+ });
980
+ break;
981
+ }
982
+
888
983
  default:
889
984
  console.log('--------------------------------------\n');
890
985
  console.log('Usage: plum <command>\n');
@@ -918,5 +1013,6 @@ switch (command) {
918
1013
  console.log(' --parallel <n> Run across n parallel workers');
919
1014
  console.log(' --browser <name> chromium | firefox (default: chromium)');
920
1015
  console.log(' create-step Interactively scaffold a new step definition');
1016
+ console.log(' create-test Scaffold a new .feature + Page.ts + Steps.ts');
921
1017
  console.log('\n--------------------------------------\n');
922
1018
  }